@kahitsan/ksui 0.19.0 → 0.20.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": "@kahitsan/ksui",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,86 @@
1
+ // U8 — CustomRenderer component tests: registered renders, unknown id falls back,
2
+ // mismatched props fall back, undeclared emit is dropped.
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { render, fireEvent } from "@solidjs/testing-library";
5
+ import CustomRenderer from "./CustomRenderer";
6
+ import { clearRenderers, registerRenderer, type RendererProps } from "../../utils/renderers";
7
+
8
+ afterEach(() => {
9
+ clearRenderers();
10
+ vi.restoreAllMocks();
11
+ });
12
+
13
+ function Card(props: RendererProps) {
14
+ return (
15
+ <div data-testid="card">
16
+ <span>{String(props.item.customer)}</span>
17
+ <button data-testid="emit-pay" onClick={() => props.emit("pay", { amount: 1 })}>
18
+ pay
19
+ </button>
20
+ <button data-testid="emit-bogus" onClick={() => props.emit("bogus")}>
21
+ bogus
22
+ </button>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ describe("CustomRenderer", () => {
28
+ it("renders a registered renderer with valid props", () => {
29
+ registerRenderer({ id: "card", consumes: { customer: "string" }, emits: ["pay"], render: Card });
30
+ const { getByTestId } = render(() => <CustomRenderer id="card" item={{ customer: "Acme" }} />);
31
+ expect(getByTestId("card").textContent).toContain("Acme");
32
+ });
33
+
34
+ it("falls back when the id is unregistered", () => {
35
+ vi.spyOn(console, "warn").mockImplementation(() => {});
36
+ const { getByTestId, queryByTestId } = render(() => (
37
+ <CustomRenderer id="missing" item={{ customer: "Acme" }} />
38
+ ));
39
+ expect(getByTestId("ksui-cr-fallback")).toBeTruthy();
40
+ expect(queryByTestId("card")).toBeNull();
41
+ });
42
+
43
+ it("falls back when props don't match the consumes contract", () => {
44
+ vi.spyOn(console, "warn").mockImplementation(() => {});
45
+ registerRenderer({ id: "card", consumes: { customer: "string" }, emits: ["pay"], render: Card });
46
+ const { getByTestId, queryByTestId } = render(() => (
47
+ <CustomRenderer id="card" item={{ customer: 123 as unknown as string }} />
48
+ ));
49
+ expect(getByTestId("ksui-cr-fallback")).toBeTruthy();
50
+ expect(queryByTestId("card")).toBeNull();
51
+ });
52
+
53
+ it("forwards a declared emit to onEmit", () => {
54
+ registerRenderer({ id: "card", consumes: { customer: "string" }, emits: ["pay"], render: Card });
55
+ const onEmit = vi.fn();
56
+ const { getByTestId } = render(() => (
57
+ <CustomRenderer id="card" item={{ customer: "Acme" }} onEmit={onEmit} />
58
+ ));
59
+ fireEvent.click(getByTestId("emit-pay"));
60
+ expect(onEmit).toHaveBeenCalledWith("pay", { amount: 1 });
61
+ });
62
+
63
+ it("drops an undeclared emit (cannot forge an interaction)", () => {
64
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
65
+ registerRenderer({ id: "card", consumes: { customer: "string" }, emits: ["pay"], render: Card });
66
+ const onEmit = vi.fn();
67
+ const { getByTestId } = render(() => (
68
+ <CustomRenderer id="card" item={{ customer: "Acme" }} onEmit={onEmit} />
69
+ ));
70
+ fireEvent.click(getByTestId("emit-bogus"));
71
+ expect(onEmit).not.toHaveBeenCalled();
72
+ expect(warn).toHaveBeenCalled();
73
+ });
74
+
75
+ it("renders a custom fallback when provided", () => {
76
+ vi.spyOn(console, "warn").mockImplementation(() => {});
77
+ const { getByTestId } = render(() => (
78
+ <CustomRenderer
79
+ id="missing"
80
+ item={{}}
81
+ fallback={(p) => <div data-testid="my-fallback">{p.id}</div>}
82
+ />
83
+ ));
84
+ expect(getByTestId("my-fallback").textContent).toBe("missing");
85
+ });
86
+ });
@@ -0,0 +1,114 @@
1
+ // U8 — CustomRenderer (Vision §8): renders a registered, schema-bound custom
2
+ // renderer by id, with a SAFE fallback when the id is unregistered or the props
3
+ // don't satisfy the renderer's declared `consumes` contract.
4
+ //
5
+ // It is a composite because it reads the in-process renderer registry
6
+ // (utils/renderers) and wires the `emit` guard. The registry holds only
7
+ // build-time, in-process components — no eval, no remote code (see renderers.ts
8
+ // for the supply-chain-trust rationale).
9
+
10
+ import type { Component } from "solid-js";
11
+ import { Show, createMemo } from "solid-js";
12
+ import { Dynamic } from "solid-js/web";
13
+ import AlertTriangle from "lucide-solid/icons/triangle-alert";
14
+ import {
15
+ getRenderer,
16
+ validateConsumes,
17
+ type RendererProps,
18
+ } from "../../utils/renderers";
19
+
20
+ const STYLE_ID = "ksui-custom-renderer-style";
21
+
22
+ function ensureStyle(): void {
23
+ if (typeof document === "undefined") return;
24
+ if (document.getElementById(STYLE_ID)) return;
25
+ const style = document.createElement("style");
26
+ style.id = STYLE_ID;
27
+ // Unscoped ksui-* classes + CSS custom properties so a host can retint without
28
+ // forking; no Tailwind, no host-brand classes (standalone-library rule).
29
+ style.textContent = `
30
+ .ksui-cr-fallback{display:flex;align-items:center;gap:0.5rem;padding:0.625rem 0.75rem;border-radius:0.5rem;font-size:0.8125rem;background:var(--ksui-cr-fallback-bg,rgba(245,158,11,0.08));border:1px solid var(--ksui-cr-fallback-border,rgba(245,158,11,0.25));color:var(--ksui-cr-fallback-fg,#fbbf24);}
31
+ .ksui-cr-fallback svg{flex:0 0 auto;}
32
+ `;
33
+ document.head.appendChild(style);
34
+ }
35
+
36
+ export interface CustomRendererProps {
37
+ /** Registered renderer id to look up (§8). */
38
+ id: string;
39
+ /** The data object to render; validated against the renderer's `consumes`. */
40
+ item: Record<string, unknown>;
41
+ /**
42
+ * Fire one of the renderer's declared `emits`. An undeclared emit is dropped
43
+ * with a console.warn — a renderer cannot forge an interaction it never
44
+ * declared (§8: it can misbehave on screen but never escalate authority).
45
+ */
46
+ onEmit?: (event: string, payload?: unknown) => void;
47
+ /**
48
+ * Optional custom fallback when the id is unknown or props don't match. When
49
+ * omitted a built-in warning chip renders (never throws — §8 graceful degrade).
50
+ */
51
+ fallback?: Component<{ id: string; reason: string }>;
52
+ }
53
+
54
+ const DefaultFallback: Component<{ id: string; reason: string }> = (props) => {
55
+ ensureStyle();
56
+ return (
57
+ <div class="ksui-cr-fallback" role="status" data-testid="ksui-cr-fallback">
58
+ <AlertTriangle size={14} />
59
+ <span>Renderer "{props.id}" unavailable</span>
60
+ </div>
61
+ );
62
+ };
63
+
64
+ export const CustomRenderer: Component<CustomRendererProps> = (props) => {
65
+ // Resolve id → definition + validate props on every change. A miss (unknown id
66
+ // OR schema mismatch) yields a reason string and the fallback renders.
67
+ const resolved = createMemo<
68
+ | { kind: "ok"; render: Component<RendererProps>; emits: readonly string[] }
69
+ | { kind: "fallback"; reason: string }
70
+ >(() => {
71
+ const def = getRenderer(props.id);
72
+ if (!def) return { kind: "fallback", reason: `unregistered id "${props.id}"` };
73
+ const v = validateConsumes(def.consumes, props.item);
74
+ if (!v.ok) return { kind: "fallback", reason: v.errors.join("; ") };
75
+ return { kind: "ok", render: def.render, emits: def.emits };
76
+ });
77
+
78
+ // Guard emits: only declared interaction points pass through (§8). An
79
+ // undeclared name is dropped + warned, never forwarded to the host.
80
+ const emit = (event: string, payload?: unknown) => {
81
+ const r = resolved();
82
+ if (r.kind !== "ok") return;
83
+ if (!r.emits.includes(event)) {
84
+ console.warn(
85
+ `[ksui] CustomRenderer "${props.id}": ignored undeclared emit "${event}" (declared: ${r.emits.join(", ") || "none"})`,
86
+ );
87
+ return;
88
+ }
89
+ props.onEmit?.(event, payload);
90
+ };
91
+
92
+ return (
93
+ <Show
94
+ when={resolved().kind === "ok"}
95
+ fallback={(() => {
96
+ const r = resolved();
97
+ const reason = r.kind === "fallback" ? r.reason : "";
98
+ // WHY warn here: a fallback means a spec referenced a renderer the bundle
99
+ // doesn't satisfy — a build/config drift the developer must see (§8).
100
+ console.warn(`[ksui] CustomRenderer falling back for "${props.id}": ${reason}`);
101
+ const Fallback = props.fallback ?? DefaultFallback;
102
+ return <Fallback id={props.id} reason={reason} />;
103
+ })()}
104
+ >
105
+ <Dynamic
106
+ component={(resolved() as { render: Component<RendererProps> }).render}
107
+ item={props.item}
108
+ emit={emit}
109
+ />
110
+ </Show>
111
+ );
112
+ };
113
+
114
+ export default CustomRenderer;
@@ -0,0 +1,100 @@
1
+ // U6 — FileField tests: state transitions, injected uploader/resolver called,
2
+ // graceful degrade on a rejected presign + a rejected upload.
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { render, fireEvent, waitFor } from "@solidjs/testing-library";
5
+ import FileField, { type AssetHandle } from "./FileField";
6
+
7
+ const imageHandle: AssetHandle = { id: "a1", name: "receipt.png", mime: "image/png", size: 2048 };
8
+
9
+ function pngFile(): File {
10
+ return new File([new Uint8Array([1, 2, 3])], "receipt.png", { type: "image/png" });
11
+ }
12
+
13
+ describe("FileField", () => {
14
+ it("starts empty with a drop zone", () => {
15
+ const { getByTestId } = render(() => (
16
+ <FileField testId="ff" onUpload={vi.fn(async () => imageHandle)} />
17
+ ));
18
+ expect(getByTestId("ff-drop")).toBeTruthy();
19
+ });
20
+
21
+ it("calls the injected uploader on pick and transitions to done", async () => {
22
+ const onUpload = vi.fn(async () => imageHandle);
23
+ const onChange = vi.fn();
24
+ const presignUrl = vi.fn(async () => "https://signed/url.png");
25
+ const { getByTestId, queryByTestId } = render(() => (
26
+ <FileField testId="ff" onUpload={onUpload} onChange={onChange} presignUrl={presignUrl} />
27
+ ));
28
+ const input = getByTestId("ff-input") as HTMLInputElement;
29
+ Object.defineProperty(input, "files", { value: [pngFile()], configurable: true });
30
+ fireEvent.change(input);
31
+
32
+ await waitFor(() => expect(onUpload).toHaveBeenCalled());
33
+ await waitFor(() => expect(getByTestId("ff-done")).toBeTruthy());
34
+ expect(onChange).toHaveBeenCalledWith(imageHandle);
35
+ // image handle → presign called for preview
36
+ await waitFor(() => expect(presignUrl).toHaveBeenCalledWith(imageHandle));
37
+ await waitFor(() => expect(getByTestId("ff-preview")).toBeTruthy());
38
+ expect(queryByTestId("ff-drop")).toBeNull();
39
+ });
40
+
41
+ it("degrades gracefully (broken thumb, no throw) when presign rejects", async () => {
42
+ const onUpload = vi.fn(async () => imageHandle);
43
+ const presignUrl = vi.fn(async () => {
44
+ throw new Error("expired");
45
+ });
46
+ vi.spyOn(console, "warn").mockImplementation(() => {});
47
+ const { getByTestId } = render(() => (
48
+ <FileField testId="ff" onUpload={onUpload} presignUrl={presignUrl} />
49
+ ));
50
+ const input = getByTestId("ff-input") as HTMLInputElement;
51
+ Object.defineProperty(input, "files", { value: [pngFile()], configurable: true });
52
+ fireEvent.change(input);
53
+
54
+ await waitFor(() => expect(getByTestId("ff-broken")).toBeTruthy());
55
+ });
56
+
57
+ it("shows a failed state when the uploader rejects (never throws)", async () => {
58
+ const onUpload = vi.fn(async () => {
59
+ throw new Error("offline");
60
+ });
61
+ vi.spyOn(console, "warn").mockImplementation(() => {});
62
+ const { getByTestId } = render(() => <FileField testId="ff" onUpload={onUpload} />);
63
+ const input = getByTestId("ff-input") as HTMLInputElement;
64
+ Object.defineProperty(input, "files", { value: [pngFile()], configurable: true });
65
+ fireEvent.change(input);
66
+
67
+ await waitFor(() => expect(getByTestId("ff-failed").textContent).toContain("offline"));
68
+ expect(getByTestId("ff-retry")).toBeTruthy();
69
+ });
70
+
71
+ it("clears the handle when removed", async () => {
72
+ const onChange = vi.fn();
73
+ const { getByTestId } = render(() => (
74
+ <FileField
75
+ testId="ff"
76
+ value={{ id: "x", name: "doc.pdf", mime: "application/pdf", size: 100 }}
77
+ onUpload={vi.fn(async () => imageHandle)}
78
+ onChange={onChange}
79
+ />
80
+ ));
81
+ expect(getByTestId("ff-done")).toBeTruthy();
82
+ fireEvent.click(getByTestId("ff-remove"));
83
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(null));
84
+ expect(getByTestId("ff-drop")).toBeTruthy();
85
+ });
86
+
87
+ it("renders a non-image handle with a file icon (no presign)", () => {
88
+ const presignUrl = vi.fn(async () => "x");
89
+ const { getByTestId } = render(() => (
90
+ <FileField
91
+ testId="ff"
92
+ value={{ id: "x", name: "doc.pdf", mime: "application/pdf", size: 100 }}
93
+ onUpload={vi.fn(async () => imageHandle)}
94
+ presignUrl={presignUrl}
95
+ />
96
+ ));
97
+ expect(getByTestId("ff-done")).toBeTruthy();
98
+ expect(presignUrl).not.toHaveBeenCalled(); // only images presign
99
+ });
100
+ });
@@ -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;