@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.19.0",
3
+ "version": "0.21.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,88 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent, screen } from "@solidjs/testing-library";
3
+ import BadgeSelect, { type BadgeSelectOption } from "./BadgeSelect";
4
+
5
+ // BadgeSelect is the generic, domain-free inline picker lifted from kserp's
6
+ // RoleBadgeSelect (U1). The caller injects options, the value↔label mapping,
7
+ // and (optionally) the badge tone; nothing here is role-specific. The popup
8
+ // renders into a Portal (document.body), so popup assertions use `screen`.
9
+
10
+ const OPTIONS: BadgeSelectOption[] = [
11
+ { value: "admin", label: "Admin", description: "Full access" },
12
+ { value: "member", label: "Member" },
13
+ ];
14
+
15
+ describe("BadgeSelect", () => {
16
+ it("renders the selected option's label on the trigger", () => {
17
+ const { getByRole } = render(() => (
18
+ <BadgeSelect value="admin" options={OPTIONS} onChange={() => {}} />
19
+ ));
20
+ expect(getByRole("button").textContent).toContain("Admin");
21
+ });
22
+
23
+ it("falls back to the raw value when no option matches", () => {
24
+ const { getByRole } = render(() => (
25
+ <BadgeSelect value="ghost" options={OPTIONS} onChange={() => {}} />
26
+ ));
27
+ expect(getByRole("button").textContent).toContain("ghost");
28
+ });
29
+
30
+ it("opens the popup and emits the chosen value on select", async () => {
31
+ const onChange = vi.fn();
32
+ const { getByRole } = render(() => (
33
+ <BadgeSelect value="admin" options={OPTIONS} onChange={onChange} />
34
+ ));
35
+ fireEvent.click(getByRole("button"));
36
+ const memberOpt = await screen.findByText("Member");
37
+ fireEvent.click(memberOpt);
38
+ expect(onChange).toHaveBeenCalledWith("member");
39
+ });
40
+
41
+ it("does not emit when re-selecting the current value", async () => {
42
+ const onChange = vi.fn();
43
+ const { getByRole } = render(() => (
44
+ <BadgeSelect value="admin" options={OPTIONS} onChange={onChange} />
45
+ ));
46
+ fireEvent.click(getByRole("button"));
47
+ // Wait for the popup, then click the option row for the active value.
48
+ await screen.findByText("Member");
49
+ const adminRows = screen.getAllByText("Admin");
50
+ fireEvent.click(adminRows[adminRows.length - 1]);
51
+ expect(onChange).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("applies the injected badgeClass to the trigger (DI tone)", () => {
55
+ const { getByRole } = render(() => (
56
+ <BadgeSelect
57
+ value="admin"
58
+ options={OPTIONS}
59
+ onChange={() => {}}
60
+ badgeClass={(v) => (v === "admin" ? "tone-special" : "tone-default")}
61
+ />
62
+ ));
63
+ expect(getByRole("button").className).toContain("tone-special");
64
+ });
65
+
66
+ it("disables the trigger when disabled", () => {
67
+ const { getByRole } = render(() => (
68
+ <BadgeSelect value="admin" options={OPTIONS} disabled onChange={() => {}} />
69
+ ));
70
+ expect((getByRole("button") as HTMLButtonElement).disabled).toBe(true);
71
+ });
72
+
73
+ it("exposes listbox/option ARIA semantics when open", async () => {
74
+ const { getByRole } = render(() => (
75
+ <BadgeSelect value="admin" options={OPTIONS} onChange={() => {}} />
76
+ ));
77
+ const trigger = getByRole("button");
78
+ expect(trigger.getAttribute("aria-haspopup")).toBe("listbox");
79
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
80
+ fireEvent.click(trigger);
81
+ const listbox = await screen.findByRole("listbox");
82
+ expect(listbox).toBeTruthy();
83
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
84
+ // The active option advertises its selected state to assistive tech.
85
+ const selected = screen.getAllByRole("option").find((o) => o.getAttribute("aria-selected") === "true");
86
+ expect(selected?.textContent).toContain("Admin");
87
+ });
88
+ });
@@ -0,0 +1,229 @@
1
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
2
+ import { Portal } from "solid-js/web";
3
+
4
+ export interface BadgeSelectOption {
5
+ /** Stable identity of the option (what `value` matches and `onChange` emits). */
6
+ value: string;
7
+ label: string;
8
+ description?: string;
9
+ }
10
+
11
+ export interface BadgeSelectProps {
12
+ /** Currently-selected option value. */
13
+ value: string;
14
+ options: BadgeSelectOption[];
15
+ disabled?: boolean;
16
+ loading?: boolean;
17
+ /** Map the selected value to the trigger badge's classes (DI). Defaults to a
18
+ * neutral zinc chip — the caller injects its own tone mapping. */
19
+ badgeClass?: (value: string) => string;
20
+ onChange: (next: string) => void | Promise<void>;
21
+ /** Show the inline search box when options exceed this count. Default 5. */
22
+ searchThreshold?: number;
23
+ searchPlaceholder?: string;
24
+ /** Tooltip on the trigger when enabled. Default "Click to change". */
25
+ title?: string;
26
+ /** Empty-state copy when there are no options at all. Default "No options". */
27
+ emptyLabel?: string;
28
+ /** Empty-state copy when a search filters everything out. Default "No matching options". */
29
+ emptyFilteredLabel?: string;
30
+ /** Copy shown while `loading`. Default "Loading…". */
31
+ loadingLabel?: string;
32
+ testId?: string;
33
+ }
34
+
35
+ const POPUP_MIN_WIDTH = 200;
36
+ const POPUP_MAX_HEIGHT = 320;
37
+ const DEFAULT_SEARCH_THRESHOLD = 5;
38
+
39
+ // Inline, click-to-edit badge picker. Renders as a clickable badge; clicking
40
+ // opens a dropdown anchored to the badge with all options. When more than
41
+ // `searchThreshold` options are present an inline search box auto-appears so a
42
+ // long list stays scannable. Selecting commits immediately via onChange.
43
+ //
44
+ // The popup is rendered into a Portal with `position: fixed`, so it escapes
45
+ // ancestors that have `overflow: hidden` (e.g. rounded card containers) and
46
+ // flips above the trigger when there isn't enough room below it.
47
+ //
48
+ // Domain-free: the caller supplies the options, the value↔label mapping, and
49
+ // (optionally) the badge tone via `badgeClass`. Nothing here knows what the
50
+ // values mean.
51
+ //
52
+ // Intentionally distinct from SearchableSelect: this is a badge-scoped single
53
+ // select — a compact inline chip trigger plus per-value tone DI (`badgeClass`),
54
+ // not a form-control picker. Kept separate so neither component grows a
55
+ // trigger-shape/styling switch; do not merge them.
56
+ export default function BadgeSelect(props: BadgeSelectProps): JSX.Element {
57
+ const [open, setOpen] = createSignal(false);
58
+ const [busy, setBusy] = createSignal(false);
59
+ const [query, setQuery] = createSignal("");
60
+ const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
61
+ let triggerRef: HTMLButtonElement | undefined;
62
+ let popupRef: HTMLDivElement | undefined;
63
+ let searchRef: HTMLInputElement | undefined;
64
+
65
+ const threshold = () => props.searchThreshold ?? DEFAULT_SEARCH_THRESHOLD;
66
+ const showSearch = () => (props.options?.length ?? 0) > threshold();
67
+
68
+ const filtered = createMemo(() => {
69
+ const q = query().trim().toLowerCase();
70
+ if (!q) return props.options;
71
+ return props.options.filter(
72
+ (o) =>
73
+ o.label.toLowerCase().includes(q) ||
74
+ o.value.toLowerCase().includes(q) ||
75
+ (o.description?.toLowerCase().includes(q) ?? false),
76
+ );
77
+ });
78
+
79
+ const defaultBadge = () => "bg-zinc-800 text-zinc-400 border border-transparent";
80
+ const badgeClass = () => props.badgeClass?.(props.value) ?? defaultBadge();
81
+
82
+ const emptyStateMessage = () => {
83
+ if (props.loading) return props.loadingLabel ?? "Loading…";
84
+ return query() ? (props.emptyFilteredLabel ?? "No matching options") : (props.emptyLabel ?? "No options");
85
+ };
86
+
87
+ const updatePosition = () => {
88
+ if (!triggerRef) return;
89
+ const rect = triggerRef.getBoundingClientRect();
90
+ const vpHeight = window.innerHeight;
91
+ const vpWidth = window.innerWidth;
92
+ const width = Math.max(POPUP_MIN_WIDTH, rect.width);
93
+ const spaceBelow = vpHeight - rect.bottom;
94
+ const spaceAbove = rect.top;
95
+ const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
96
+ const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
97
+ const maxHeight = Math.max(
98
+ 160,
99
+ Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
100
+ );
101
+ const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
102
+ setPopupStyle({
103
+ position: "fixed",
104
+ top: `${top}px`,
105
+ left: `${left}px`,
106
+ width: `${width}px`,
107
+ "max-height": `${maxHeight}px`,
108
+ });
109
+ };
110
+
111
+ createEffect(() => {
112
+ if (!open()) return;
113
+ setQuery("");
114
+ updatePosition();
115
+ if (showSearch()) queueMicrotask(() => searchRef?.focus());
116
+
117
+ const onDocClick = (e: MouseEvent) => {
118
+ const target = e.target as Node;
119
+ if (triggerRef?.contains(target)) return;
120
+ if (popupRef?.contains(target)) return;
121
+ setOpen(false);
122
+ };
123
+ const onEsc = (e: KeyboardEvent) => {
124
+ if (e.key === "Escape") setOpen(false);
125
+ };
126
+ const onReflow = () => updatePosition();
127
+
128
+ document.addEventListener("mousedown", onDocClick);
129
+ document.addEventListener("keydown", onEsc);
130
+ window.addEventListener("resize", onReflow);
131
+ window.addEventListener("scroll", onReflow, true);
132
+ onCleanup(() => {
133
+ document.removeEventListener("mousedown", onDocClick);
134
+ document.removeEventListener("keydown", onEsc);
135
+ window.removeEventListener("resize", onReflow);
136
+ window.removeEventListener("scroll", onReflow, true);
137
+ });
138
+ });
139
+
140
+ const handleSelect = async (next: string) => {
141
+ if (next === props.value) {
142
+ setOpen(false);
143
+ return;
144
+ }
145
+ setBusy(true);
146
+ try {
147
+ await props.onChange(next);
148
+ } finally {
149
+ setBusy(false);
150
+ setOpen(false);
151
+ }
152
+ };
153
+
154
+ const currentLabel = () =>
155
+ props.options.find((o) => o.value === props.value)?.label ?? props.value;
156
+
157
+ return (
158
+ <div class="relative inline-block">
159
+ <button
160
+ ref={triggerRef}
161
+ type="button"
162
+ data-testid={props.testId}
163
+ aria-haspopup="listbox"
164
+ aria-expanded={open()}
165
+ disabled={props.disabled || busy()}
166
+ onClick={() => !props.disabled && setOpen((o) => !o)}
167
+ class={`text-xs font-medium px-1.5 py-0.5 rounded cursor-pointer transition-colors hover:ring-1 hover:ring-amber-500/40 ${badgeClass()} ${
168
+ props.disabled ? "cursor-not-allowed opacity-60" : ""
169
+ }`}
170
+ title={props.disabled ? "" : (props.title ?? "Click to change")}
171
+ >
172
+ {busy() ? "…" : currentLabel()}
173
+ </button>
174
+ <Show when={open()}>
175
+ <Portal>
176
+ <div
177
+ ref={popupRef}
178
+ role="listbox"
179
+ class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
180
+ style={popupStyle()}
181
+ >
182
+ <Show when={showSearch()}>
183
+ <div class="px-2 py-1.5 border-b border-zinc-800">
184
+ <input
185
+ ref={searchRef}
186
+ type="text"
187
+ value={query()}
188
+ onInput={(e) => setQuery(e.currentTarget.value)}
189
+ placeholder={props.searchPlaceholder ?? "Search…"}
190
+ class="w-full px-2 py-1 text-xs bg-zinc-950 border border-zinc-800 rounded text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-amber-500/50"
191
+ />
192
+ </div>
193
+ </Show>
194
+ <div class="flex-1 overflow-y-auto">
195
+ <Show
196
+ when={!props.loading && filtered().length > 0}
197
+ fallback={<div class="px-3 py-2 text-xs text-zinc-500">{emptyStateMessage()}</div>}
198
+ >
199
+ <For each={filtered()}>
200
+ {(opt) => (
201
+ <button
202
+ type="button"
203
+ role="option"
204
+ aria-selected={opt.value === props.value}
205
+ onClick={() => handleSelect(opt.value)}
206
+ class={`w-full text-left px-3 py-2 text-xs hover:bg-amber-500/10 transition-colors flex items-center justify-between gap-2 ${
207
+ opt.value === props.value ? "text-amber-400" : "text-zinc-200"
208
+ }`}
209
+ >
210
+ <span class="flex flex-col">
211
+ <span class="font-medium">{opt.label}</span>
212
+ <Show when={opt.description}>
213
+ <span class="text-[10px] text-zinc-500">{opt.description}</span>
214
+ </Show>
215
+ </span>
216
+ <Show when={opt.value === props.value}>
217
+ <span class="text-amber-400" aria-hidden="true">✓</span>
218
+ </Show>
219
+ </button>
220
+ )}
221
+ </For>
222
+ </Show>
223
+ </div>
224
+ </div>
225
+ </Portal>
226
+ </Show>
227
+ </div>
228
+ );
229
+ }
@@ -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,112 @@
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("loads a pre-seeded image value via the presigned (https) URL into the preview", async () => {
88
+ const presignUrl = vi.fn(async () => "https://signed.example/preview.png?sig=abc");
89
+ const { getByTestId } = render(() => (
90
+ <FileField testId="ff" value={imageHandle} onUpload={vi.fn(async () => imageHandle)} presignUrl={presignUrl} />
91
+ ));
92
+ // A value handle present at mount presigns eagerly and shows the done card.
93
+ expect(getByTestId("ff-done")).toBeTruthy();
94
+ await waitFor(() => expect(presignUrl).toHaveBeenCalledWith(imageHandle));
95
+ const img = (await waitFor(() => getByTestId("ff-preview"))) as HTMLImageElement;
96
+ expect(img.getAttribute("src")).toBe("https://signed.example/preview.png?sig=abc");
97
+ });
98
+
99
+ it("renders a non-image handle with a file icon (no presign)", () => {
100
+ const presignUrl = vi.fn(async () => "x");
101
+ const { getByTestId } = render(() => (
102
+ <FileField
103
+ testId="ff"
104
+ value={{ id: "x", name: "doc.pdf", mime: "application/pdf", size: 100 }}
105
+ onUpload={vi.fn(async () => imageHandle)}
106
+ presignUrl={presignUrl}
107
+ />
108
+ ));
109
+ expect(getByTestId("ff-done")).toBeTruthy();
110
+ expect(presignUrl).not.toHaveBeenCalled(); // only images presign
111
+ });
112
+ });