@ilha/store 0.1.4 → 0.2.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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A zustand-shaped reactive store for [Ilha](https://github.com/ilhajs/ilha) islands. Backed by [alien-signals](https://github.com/stackblitz/alien-signals) — the same engine that powers `ilha` core state — for shared global state that lives outside any single island.
4
4
 
5
+ Includes a `/form` subpath with unopinionated, type-safe form helpers built on [Standard Schema](https://standardschema.dev) — works with Zod, Valibot, ArkType, or any compatible library.
6
+
5
7
  ---
6
8
 
7
9
  ## Installation
@@ -12,6 +14,19 @@ bun add @ilha/store
12
14
 
13
15
  ---
14
16
 
17
+ ## When to Use
18
+
19
+ `ilha` state is **island-local** — signals are scoped to a single component instance. Use `@ilha/store` when you need state that is:
20
+
21
+ - **Shared across multiple islands** — e.g. a cart, auth session, or theme
22
+ - **Updated from outside an island** — e.g. from a WebSocket handler or a global event bus
23
+ - **Persisted or derived globally** — e.g. synced to `localStorage` via a `subscribe` listener
24
+ - **Form state** — pair `createStore` with `@ilha/store/form` helpers for typed validation, error mapping, and submission handling
25
+
26
+ For state that only one island reads and writes, prefer `ilha`'s built-in `.state()`.
27
+
28
+ ---
29
+
15
30
  ## Quick Start
16
31
 
17
32
  ```ts
@@ -25,18 +40,6 @@ store.getState(); // → { count: 1 }
25
40
 
26
41
  ---
27
42
 
28
- ## When to Use
29
-
30
- `ilha` state is **island-local** — signals are scoped to a single component instance. Use `@ilha/store` when you need state that is:
31
-
32
- - **Shared across multiple islands** — e.g. a cart, auth session, or theme
33
- - **Updated from outside an island** — e.g. from a WebSocket handler or a global event bus
34
- - **Persisted or derived globally** — e.g. synced to `localStorage` via a `subscribe` listener
35
-
36
- For state that only one island reads and writes, prefer `ilha`'s built-in `.state()`.
37
-
38
- ---
39
-
40
43
  ## API
41
44
 
42
45
  ### `createStore(initialState, actions?)`
@@ -132,7 +135,7 @@ const unsub = store.subscribe(
132
135
  Reactively renders a store-driven HTML string into a DOM element whenever state changes. The render function may return a plain string or an `html\`\`` tagged template.
133
136
 
134
137
  ```ts
135
- import { html } from "@ilha/store";
138
+ import { html } from "ilha";
136
139
 
137
140
  const unsub = store.bind(
138
141
  document.getElementById("counter")!,
@@ -161,8 +164,8 @@ store.bind(
161
164
  The most common pattern is reading the store inside an island's `.effect()` and calling `store.subscribe()` to drive reactive re-renders:
162
165
 
163
166
  ```ts
164
- import { createStore, html } from "@ilha/store";
165
- import ilha from "ilha";
167
+ import { createStore } from "@ilha/store";
168
+ import ilha, { html } from "ilha";
166
169
 
167
170
  export const cartStore = createStore({ items: [] as string[] }, (set, get) => ({
168
171
  add(item: string) {
@@ -192,6 +195,116 @@ export const CartIsland = ilha
192
195
 
193
196
  ---
194
197
 
198
+ ## Forms — `@ilha/store/form`
199
+
200
+ Three small helpers for building typed, validated forms with any [Standard Schema](https://standardschema.dev)-compatible library. They are **unopinionated** — you compose them with `createStore` however you like; nothing is imposed about your form's state shape.
201
+
202
+ ```ts
203
+ import { extractFormData, validateWithSchema, issuesToErrors } from "@ilha/store/form";
204
+ ```
205
+
206
+ ### `extractFormData(source)`
207
+
208
+ Turns an `HTMLFormElement` (or a `FormData` instance) into a plain object. Handles the `string` vs `string[]` dance correctly: single fields stay scalar, repeated keys (checkbox groups, multi-selects) collapse to arrays. File inputs pass through as `File` values.
209
+
210
+ ```ts
211
+ const data = extractFormData(event.target as HTMLFormElement);
212
+ // → { email: "ada@example.com", role: ["admin", "editor"] }
213
+ ```
214
+
215
+ ### `validateWithSchema(schema, data)`
216
+
217
+ Runs a Standard Schema synchronously and returns a discriminated union — **never throws**.
218
+
219
+ ```ts
220
+ const result = validateWithSchema(SignInSchema, data);
221
+ if (result.ok) {
222
+ result.data; // ← fully typed schema output
223
+ } else {
224
+ result.issues; // ← ReadonlyArray<StandardSchemaV1.Issue>
225
+ }
226
+ ```
227
+
228
+ If your schema has async refinements (e.g. server-side uniqueness checks), use `validateWithSchemaAsync` instead — same return shape, always returns a `Promise`.
229
+
230
+ ### `issuesToErrors(issues)`
231
+
232
+ Flattens Standard Schema issues into a per-field error map keyed by dot-separated path. Form-level errors (issues with no path) land under the `""` key.
233
+
234
+ ```ts
235
+ issuesToErrors([
236
+ { message: "Required", path: ["email"] },
237
+ { message: "Invalid", path: ["user", "email"] },
238
+ ]);
239
+ // → { email: ["Required"], "user.email": ["Invalid"] }
240
+ ```
241
+
242
+ ---
243
+
244
+ ### Full example — contact form
245
+
246
+ ```ts
247
+ import { createStore } from "@ilha/store";
248
+ import { extractFormData, validateWithSchema, issuesToErrors } from "@ilha/store/form";
249
+ import type { FormErrors } from "@ilha/store/form";
250
+ import ilha, { html } from "ilha";
251
+ import { z } from "zod";
252
+
253
+ const ContactSchema = z.object({
254
+ name: z.string().min(1, "Name is required"),
255
+ email: z.email("Invalid email"),
256
+ message: z.string().min(10, "Message must be at least 10 characters"),
257
+ });
258
+
259
+ const formStore = createStore({ errors: {} as FormErrors }, (set) => ({
260
+ submit(event: SubmitEvent) {
261
+ const result = validateWithSchema(
262
+ ContactSchema,
263
+ extractFormData(event.target as HTMLFormElement),
264
+ );
265
+ if (result.ok) {
266
+ console.log("submitting:", result.data);
267
+ set({ errors: {} });
268
+ } else {
269
+ set({ errors: issuesToErrors(result.issues) });
270
+ }
271
+ },
272
+ }));
273
+
274
+ export default ilha
275
+ .on("form@submit", ({ event }) => {
276
+ event.preventDefault();
277
+ formStore.getState().submit(event);
278
+ })
279
+ .render(() => {
280
+ const errors = formStore.getState().errors;
281
+ return html`
282
+ <form>
283
+ <label>
284
+ Name
285
+ <input name="name" />
286
+ ${errors.name ? html`<p role="alert">${errors.name.join(", ")}</p>` : ""}
287
+ </label>
288
+ <label>
289
+ Email
290
+ <input name="email" type="email" />
291
+ ${errors.email ? html`<p role="alert">${errors.email.join(", ")}</p>` : ""}
292
+ </label>
293
+ <label>
294
+ Message
295
+ <textarea name="message"></textarea>
296
+ ${errors.message ? html`<p role="alert">${errors.message.join(", ")}</p>` : ""}
297
+ </label>
298
+ <button type="submit">Send</button>
299
+ </form>
300
+ `;
301
+ });
302
+ ```
303
+
304
+ The store holds errors, the schema drives types, and `extractFormData` + `validateWithSchema` + `issuesToErrors` form a straight pipeline from DOM to error state.
305
+
306
+ ---
307
+
195
308
  ## TypeScript
196
309
 
197
310
  Key exported types:
@@ -206,6 +319,12 @@ import type {
206
319
  RenderResult, // string | RawHtml
207
320
  Unsub, // () => void
208
321
  } from "@ilha/store";
322
+
323
+ import type {
324
+ StandardSchemaV1, // the Standard Schema spec interface
325
+ FormResult, // discriminated union: { ok: true, data } | { ok: false, issues }
326
+ FormErrors, // Record<string, string[]>
327
+ } from "@ilha/store/form";
209
328
  ```
210
329
 
211
330
  ---
package/dist/form.d.ts ADDED
@@ -0,0 +1,102 @@
1
+ //#region src/form.d.ts
2
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
3
+ readonly "~standard": StandardSchemaV1.Props<Input, Output>;
4
+ }
5
+ declare namespace StandardSchemaV1 {
6
+ interface Props<Input = unknown, Output = Input> {
7
+ readonly version: 1;
8
+ readonly vendor: string;
9
+ readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
10
+ readonly types?: Types<Input, Output> | undefined;
11
+ }
12
+ type Result<Output> = SuccessResult<Output> | FailureResult;
13
+ interface SuccessResult<Output> {
14
+ readonly value: Output;
15
+ readonly issues?: undefined;
16
+ }
17
+ interface FailureResult {
18
+ readonly issues: ReadonlyArray<Issue>;
19
+ }
20
+ interface Issue {
21
+ readonly message: string;
22
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
23
+ }
24
+ interface PathSegment {
25
+ readonly key: PropertyKey;
26
+ }
27
+ interface Types<Input, Output> {
28
+ readonly input: Input;
29
+ readonly output: Output;
30
+ }
31
+ type InferInput<S extends StandardSchemaV1> = NonNullable<S["~standard"]["types"]>["input"];
32
+ type InferOutput<S extends StandardSchemaV1> = NonNullable<S["~standard"]["types"]>["output"];
33
+ }
34
+ /**
35
+ * Discriminated union result from validating form values.
36
+ * Never throws — validation failures are returned as data.
37
+ */
38
+ type FormResult<T> = {
39
+ ok: true;
40
+ data: T;
41
+ } | {
42
+ ok: false;
43
+ issues: ReadonlyArray<StandardSchemaV1.Issue>;
44
+ };
45
+ /**
46
+ * Per-field error map. Keys are dot-separated field paths matching the
47
+ * schema's issue path (e.g. `"user.email"`). Values are arrays of messages
48
+ * so multiple failing rules on a single field are all surfaced.
49
+ */
50
+ type FormErrors = Record<string, string[]>;
51
+ /**
52
+ * Extract a plain object from a `<form>` element or `FormData` instance.
53
+ * Duplicate keys (checkbox groups, `<select multiple>`) collapse to a
54
+ * `FormDataEntryValue[]`; single keys stay as `FormDataEntryValue`. File
55
+ * inputs are preserved as `File` values — pass them straight through to
56
+ * your schema.
57
+ *
58
+ * @example
59
+ * ilha.on("form@submit", ({ event }) => {
60
+ * event.preventDefault();
61
+ * const data = extractFormData(event.target as HTMLFormElement);
62
+ * const result = validateWithSchema(SignInSchema, data);
63
+ * // ...
64
+ * });
65
+ */
66
+ declare function extractFormData(source: HTMLFormElement | FormData): Record<string, unknown>;
67
+ /**
68
+ * Run a Standard Schema synchronously and return a discriminated union.
69
+ * Never throws. If the schema returns a Promise (i.e. it has async refinements),
70
+ * a warning is logged and a failure result is returned — pair it with an
71
+ * async schema by awaiting `schema["~standard"].validate(...)` directly instead.
72
+ *
73
+ * Compatible with Zod, Valibot, ArkType, and any other Standard Schema library.
74
+ *
75
+ * @example
76
+ * const result = validateWithSchema(SignInSchema, extractFormData(form));
77
+ * if (result.ok) {
78
+ * formStore.setState({ data: result.data, errors: {} });
79
+ * } else {
80
+ * formStore.setState({ errors: issuesToErrors(result.issues) });
81
+ * }
82
+ */
83
+ declare function validateWithSchema<S extends StandardSchemaV1>(schema: S, data: unknown): FormResult<StandardSchemaV1.InferOutput<S>>;
84
+ /**
85
+ * Async variant of {@link validateWithSchema}. Always returns a Promise,
86
+ * supports both sync and async schemas. Use this when your schema has
87
+ * async refinements (e.g. uniqueness checks against a server).
88
+ */
89
+ declare function validateWithSchemaAsync<S extends StandardSchemaV1>(schema: S, data: unknown): Promise<FormResult<StandardSchemaV1.InferOutput<S>>>;
90
+ /**
91
+ * Flatten Standard Schema issues into a per-field error map keyed by
92
+ * dot-separated path. Issues without a path are grouped under the empty
93
+ * string key — useful for form-level errors.
94
+ *
95
+ * @example
96
+ * // issues: [{ message: "Invalid email", path: ["email"] }]
97
+ * issuesToErrors(issues);
98
+ * // => { email: ["Invalid email"] }
99
+ */
100
+ declare function issuesToErrors(issues: ReadonlyArray<StandardSchemaV1.Issue>): FormErrors;
101
+ //#endregion
102
+ export { FormErrors, FormResult, StandardSchemaV1, extractFormData, issuesToErrors, validateWithSchema, validateWithSchemaAsync };
package/dist/form.js ADDED
@@ -0,0 +1,117 @@
1
+ //#region src/form.ts
2
+ /**
3
+ * Extract a plain object from a `<form>` element or `FormData` instance.
4
+ * Duplicate keys (checkbox groups, `<select multiple>`) collapse to a
5
+ * `FormDataEntryValue[]`; single keys stay as `FormDataEntryValue`. File
6
+ * inputs are preserved as `File` values — pass them straight through to
7
+ * your schema.
8
+ *
9
+ * @example
10
+ * ilha.on("form@submit", ({ event }) => {
11
+ * event.preventDefault();
12
+ * const data = extractFormData(event.target as HTMLFormElement);
13
+ * const result = validateWithSchema(SignInSchema, data);
14
+ * // ...
15
+ * });
16
+ */
17
+ function extractFormData(source) {
18
+ const data = source instanceof FormData ? source : new FormData(source);
19
+ const result = Object.create(null);
20
+ for (const key of new Set(data.keys())) {
21
+ const values = data.getAll(key);
22
+ result[key] = values.length === 1 ? values[0] : values;
23
+ }
24
+ return result;
25
+ }
26
+ /**
27
+ * Run a Standard Schema synchronously and return a discriminated union.
28
+ * Never throws. If the schema returns a Promise (i.e. it has async refinements),
29
+ * a warning is logged and a failure result is returned — pair it with an
30
+ * async schema by awaiting `schema["~standard"].validate(...)` directly instead.
31
+ *
32
+ * Compatible with Zod, Valibot, ArkType, and any other Standard Schema library.
33
+ *
34
+ * @example
35
+ * const result = validateWithSchema(SignInSchema, extractFormData(form));
36
+ * if (result.ok) {
37
+ * formStore.setState({ data: result.data, errors: {} });
38
+ * } else {
39
+ * formStore.setState({ errors: issuesToErrors(result.issues) });
40
+ * }
41
+ */
42
+ function validateWithSchema(schema, data) {
43
+ let result;
44
+ try {
45
+ result = schema["~standard"].validate(data);
46
+ } catch (error) {
47
+ const message = error instanceof Error ? error.message : "Schema validation threw: " + String(error);
48
+ console.warn("[@ilha/store/form] Schema validation threw an exception:", message);
49
+ return {
50
+ ok: false,
51
+ issues: [{ message }]
52
+ };
53
+ }
54
+ if (result instanceof Promise) {
55
+ console.warn("[@ilha/store/form] Schema validation returned a Promise. validateWithSchema is synchronous — use validateWithSchemaAsync or call schema['~standard'].validate(...) directly for async schemas.");
56
+ result.catch(() => {});
57
+ return {
58
+ ok: false,
59
+ issues: [{ message: "Async schema validation is not supported by validateWithSchema." }]
60
+ };
61
+ }
62
+ if (result.issues !== void 0) return {
63
+ ok: false,
64
+ issues: result.issues
65
+ };
66
+ return {
67
+ ok: true,
68
+ data: result.value
69
+ };
70
+ }
71
+ /**
72
+ * Async variant of {@link validateWithSchema}. Always returns a Promise,
73
+ * supports both sync and async schemas. Use this when your schema has
74
+ * async refinements (e.g. uniqueness checks against a server).
75
+ */
76
+ async function validateWithSchemaAsync(schema, data) {
77
+ let result;
78
+ try {
79
+ result = await schema["~standard"].validate(data);
80
+ } catch (error) {
81
+ const message = error instanceof Error ? error.message : "Schema validation threw: " + String(error);
82
+ console.warn("[@ilha/store/form] Schema validation threw an exception:", message);
83
+ return {
84
+ ok: false,
85
+ issues: [{ message }]
86
+ };
87
+ }
88
+ if (result.issues !== void 0) return {
89
+ ok: false,
90
+ issues: result.issues
91
+ };
92
+ return {
93
+ ok: true,
94
+ data: result.value
95
+ };
96
+ }
97
+ /**
98
+ * Flatten Standard Schema issues into a per-field error map keyed by
99
+ * dot-separated path. Issues without a path are grouped under the empty
100
+ * string key — useful for form-level errors.
101
+ *
102
+ * @example
103
+ * // issues: [{ message: "Invalid email", path: ["email"] }]
104
+ * issuesToErrors(issues);
105
+ * // => { email: ["Invalid email"] }
106
+ */
107
+ function issuesToErrors(issues) {
108
+ const errors = Object.create(null);
109
+ for (const issue of issues) {
110
+ const path = issue.path?.map((p) => typeof p === "object" ? String(p.key) : String(p)).join(".") ?? "";
111
+ if (!errors[path]) errors[path] = [];
112
+ errors[path].push(issue.message);
113
+ }
114
+ return errors;
115
+ }
116
+ //#endregion
117
+ export { extractFormData, issuesToErrors, validateWithSchema, validateWithSchemaAsync };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/store",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Typed store.",
5
5
  "license": "MIT",
6
6
  "author": "Ryuz <ryuzer@proton.me>",
@@ -16,6 +16,10 @@
16
16
  ".": {
17
17
  "types": "./dist/index.d.ts",
18
18
  "import": "./dist/index.js"
19
+ },
20
+ "./form": {
21
+ "types": "./dist/form.d.ts",
22
+ "import": "./dist/form.js"
19
23
  }
20
24
  },
21
25
  "scripts": {