foldkit 0.57.0 → 0.58.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
@@ -13,7 +13,7 @@
13
13
  <h3 align="center">The frontend framework for correctness.</h3>
14
14
 
15
15
  <p align="center">
16
- <a href="https://foldkit.dev"><strong>Documentation</strong></a> · <a href="https://foldkit.dev/example-apps"><strong>Examples</strong></a> · <a href="https://foldkit.dev/getting-started"><strong>Getting Started</strong></a>
16
+ <a href="https://foldkit.dev"><strong>Documentation</strong></a> · <a href="https://foldkit.dev/manifesto"><strong>Manifesto</strong></a> · <a href="https://foldkit.dev/example-apps"><strong>Examples</strong></a> · <a href="https://foldkit.dev/getting-started"><strong>Getting Started</strong></a>
17
17
  </p>
18
18
 
19
19
  ---
@@ -33,6 +33,10 @@ It's not incremental. There's no React interop, no escape hatch from Effect, no
33
33
 
34
34
  Every Foldkit application is an [Effect](https://effect.website/) program. Your Model is a [Schema](https://effect.website/docs/schema/introduction/). Side effects are values you return, not callbacks you fire — the runtime handles when and how. If you already know Effect, Foldkit feels natural. If you're new to Effect, Foldkit is a great way to immerse yourself in it.
35
35
 
36
+ ## Coming from React?
37
+
38
+ [Coming from React](https://foldkit.dev/coming-from-react) is a guided walk through the differences. [Foldkit vs React: Side by Side](https://foldkit.dev/foldkit-vs-react-side-by-side) implements the same pixel-art editor in both frameworks so you can read them line by line.
39
+
36
40
  ## Get Started
37
41
 
38
42
  `create-foldkit-app` is the recommended way to start a new project. It scaffolds a complete setup with Tailwind, TypeScript, ESLint, Prettier, and the Vite plugin for state-preserving HMR — and lets you choose from a set of examples as your starting point.
@@ -136,16 +140,21 @@ Source: [examples/counter/src/main.ts](https://github.com/foldkit/foldkit/blob/m
136
140
 
137
141
  Foldkit is a complete system, not a collection of libraries you stitch together.
138
142
 
139
- - **Commands** Side effects are named Effects that return Messages and are executed by the runtime. Each Command has a `name` for identification in tracing and testing, and an `effect` that the runtime runs. Use any Effect combinator you want retry, timeout, race, parallel.
140
- - **Routing** Type-safe bidirectional routing. URLs parse into typed routes and routes build back into URLs. No string matching, no mismatches between parsing and building.
141
- - **Subscriptions** Declare which streams your app needs as a function of the Model. The runtime diffs and switches them when the Model changes.
142
- - **Managed Resources** Model-driven lifecycle for long-lived browser resources like WebSockets, AudioContext, and RTCPeerConnection. Acquire on state change, release on cleanup.
143
- - **UI Components** Dialog, menu, tabs, listbox, disclosure fully accessible primitives that are easy to style and customize.
144
- - **Field Validation** Per-field validation state modeled as a discriminated union. Define rules as data, apply them in update, and the Model tracks the result.
145
- - **Virtual DOM** Declarative Views powered by [Snabbdom](https://github.com/snabbdom/snabbdom). Fast, keyed diffing. Views are plain functions of your Model.
146
- - **DevTools** Built-in overlay for inspecting Messages, Model state, and Commands. Time-travel mode lets you jump to any point in your app's history.
147
- - **Testing** Simulate the update loop in tests. Send Messages, resolve Commands inline, and assert on the Model. No mocking libraries, no fake timers, no setup or teardown.
148
- - **HMR** Vite plugin with state-preserving hot module replacement. Change your view, keep your state.
143
+ - **Commands**: Side effects are named Effects that return Messages and are executed by the runtime. Define them with `Command.define`, passing the result Message schemas so the Effect's return type stays in lockstep with your Messages. Use any Effect combinator you want: retry, timeout, race, parallel. You write the Effect, the runtime runs it.
144
+ - **Routing**: Type-safe bidirectional routing built from parser combinators. URLs parse into typed Routes and Routes build back into URLs. No string matching, no mismatches between parsing and building.
145
+ - **Subscriptions**: Declare which streams your app needs as a function of the Model. The runtime diffs and switches them as the Model changes.
146
+ - **Managed Resources**: Model-driven lifecycle for long-lived browser resources like WebSockets, AudioContext, and RTCPeerConnection. Acquire on state change, release on cleanup.
147
+ - **Submodels**: A pattern for composing nested modules. A child owns its own Model, Messages, update function, and view; the parent embeds it and wraps child Messages in a `Got*Message` envelope. The pattern scales unchanged from a login form to a multi-page app.
148
+ - **OutMessage**: A typed channel for a child Submodel to emit domain events up to its parent, so the parent reacts to meaningful facts instead of internal child Messages.
149
+ - **UI Components**: Accessible, keyboard-friendly primitives covering Button, Checkbox, Combobox, Dialog, Disclosure, DragAndDrop, Fieldset, Input, Listbox, Menu, Popover, RadioGroup, Select, Switch, Tabs, Textarea, and Transition. Every component is a Submodel with a typed `ViewConfig`, domain-event callbacks like `onSelected`, `onClosed`, and `onToggled`, and `className` plus `attributes` props on every slot for styling and extension. Animated components share a `Transition` Submodel that coordinates CSS enter and leave animations.
150
+ - **Field Validation**: Per-field validation state modeled as a discriminated union. Define rules as data, apply them in update, and the Model tracks the result.
151
+ - **Virtual DOM**: Declarative views powered by [Snabbdom](https://github.com/snabbdom/snabbdom), with lazy memoization and fast, keyed diffing. Views are plain functions of your Model.
152
+ - **DevTools**: Built-in overlay for inspecting Messages, Model state, and Commands. Time-travel mode lets you jump to any point in history, Inspect mode browses snapshots without pausing, and Submodel drill-in filtering scopes the Message list to any nested module.
153
+ - **Crash View and Reporting**: Configure `crash.view` to render a custom fallback UI when the update loop throws. A `crash.report` callback fires first with the error, Model, and triggering Message, so you can ship it straight to Sentry or your logger.
154
+ - **Story Testing**: Exercise the update function directly. Send Messages, resolve Commands inline with `resolve` and `resolveAll`, and assert with focused helpers: `Story.model`, `Story.expectHasCommands`, `Story.expectExactCommands`, `Story.expectNoCommands`, and `Story.expectOutMessage`. No mocking libraries, no fake timers.
155
+ - **Scene Testing**: Drive your app the way a user does. Scene renders your real view, then clicks buttons, types into inputs, presses keys, and asserts on what's on screen. Accessible locators (`role`, `label`, `placeholder`, `altText`, `title`, `testId`, `displayValue`) with full options (`name`, `level`, `checked`, `selected`, `pressed`, `expanded`, `disabled`), multi-match `Scene.all` with `Scene.filter` and `Scene.nth`, scoped steps via `Scene.inside`, pointer events, event bubbling, and Vitest matchers like `toHaveText`, `toBeVisible`, `toHaveAccessibleName`, and `toHaveCount`. API parity with React Testing Library and Playwright, without a browser.
156
+ - **Slow-View Monitoring**: Wire `slowView` on `makeProgram` to catch renders that exceed a threshold you set. The callback fires with the current Model, the triggering Message, and the render duration, so you can log it, sample it, or ship it to your observability tool.
157
+ - **HMR**: Vite plugin with state-preserving hot module replacement. Change your view, keep your state.
149
158
 
150
159
  ## Correctness You (And Your LLM) Can See
151
160
 
@@ -168,6 +177,7 @@ This is what makes Foldkit unusually AI-friendly. The same property that makes t
168
177
  - **[Shopping Cart](https://foldkit.dev/example-apps/shopping-cart)** — Nested models and complex state
169
178
  - **[WebSocket Chat](https://foldkit.dev/example-apps/websocket-chat)** — Managed Resources with WebSocket integration
170
179
  - **[Kanban](https://foldkit.dev/example-apps/kanban)** — Drag-and-drop kanban board with cross-column reordering and keyboard navigation
180
+ - **[Pixel Art](https://foldkit.dev/example-apps/pixel-art)** — Grid-based pixel editor with painting, erasing, and palette selection
171
181
  - **[UI Showcase](https://foldkit.dev/example-apps/ui-showcase)** — Interactive showcase of every Foldkit UI component
172
182
  - **[Typing Game](https://github.com/foldkit/foldkit/tree/main/packages/typing-game)** — Multiplayer typing game with Effect RPC backend ([play it live](https://typingterminal.com))
173
183
 
@@ -0,0 +1,10 @@
1
+ declare const FileReadError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
2
+ readonly _tag: "FileReadError";
3
+ } & Readonly<A>;
4
+ /** Error raised when a `FileReader` operation fails. */
5
+ export declare class FileReadError extends FileReadError_base<{
6
+ readonly reason: string;
7
+ }> {
8
+ }
9
+ export {};
10
+ //# sourceMappingURL=error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/file/error.ts"],"names":[],"mappings":";;;AAEA,wDAAwD;AACxD,qBAAa,aAAc,SAAQ,mBAAkC;IACnE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB,CAAC;CAAG"}
@@ -0,0 +1,4 @@
1
+ import { Data } from 'effect';
2
+ /** Error raised when a `FileReader` operation fails. */
3
+ export class FileReadError extends Data.TaggedError('FileReadError') {
4
+ }
@@ -0,0 +1,24 @@
1
+ import { Schema as S } from 'effect';
2
+ /**
3
+ * A file selected by the user. Direct alias for the browser's native `File`
4
+ * type — opaque by convention (Foldkit never constructs files itself, only
5
+ * receives them from `File.select`, `File.selectMultiple`, or from
6
+ * `OnFileChange`/`OnDropFiles` event attributes).
7
+ */
8
+ export type File = globalThis.File;
9
+ /**
10
+ * Schema that accepts any value that is an instance of the DOM `File` class.
11
+ * Use in Model fields that hold user-selected files:
12
+ *
13
+ * ```ts
14
+ * attachedResume: S.OptionFromSelf(File.File)
15
+ * ```
16
+ */
17
+ export declare const File: S.Schema<File>;
18
+ /** The file's name including extension, as reported by the browser. */
19
+ export declare const name: (file: File) => string;
20
+ /** The file's size in bytes. */
21
+ export declare const size: (file: File) => number;
22
+ /** The file's MIME type (e.g. `"application/pdf"`), or empty string if the browser cannot determine one. */
23
+ export declare const mimeType: (file: File) => string;
24
+ //# sourceMappingURL=file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/file/file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC,EAAE,MAAM,QAAQ,CAAA;AAEpC;;;;;GAKG;AACH,MAAM,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAA;AAElC;;;;;;;GAOG;AACH,eAAO,MAAM,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAiC,CAAA;AAEjE,uEAAuE;AACvE,eAAO,MAAM,IAAI,GAAI,MAAM,IAAI,KAAG,MAAmB,CAAA;AAErD,gCAAgC;AAChC,eAAO,MAAM,IAAI,GAAI,MAAM,IAAI,KAAG,MAAmB,CAAA;AAErD,4GAA4G;AAC5G,eAAO,MAAM,QAAQ,GAAI,MAAM,IAAI,KAAG,MAAmB,CAAA"}
@@ -0,0 +1,16 @@
1
+ import { Schema as S } from 'effect';
2
+ /**
3
+ * Schema that accepts any value that is an instance of the DOM `File` class.
4
+ * Use in Model fields that hold user-selected files:
5
+ *
6
+ * ```ts
7
+ * attachedResume: S.OptionFromSelf(File.File)
8
+ * ```
9
+ */
10
+ export const File = S.instanceOf(globalThis.File);
11
+ /** The file's name including extension, as reported by the browser. */
12
+ export const name = (file) => file.name;
13
+ /** The file's size in bytes. */
14
+ export const size = (file) => file.size;
15
+ /** The file's MIME type (e.g. `"application/pdf"`), or empty string if the browser cannot determine one. */
16
+ export const mimeType = (file) => file.type;
@@ -0,0 +1,5 @@
1
+ export { File, mimeType, name, size } from './file';
2
+ export { FileReadError } from './error';
3
+ export { select, selectMultiple } from './select';
4
+ export { readAsArrayBuffer, readAsDataUrl, readAsText } from './reader';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/file/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AACvC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AACjD,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA"}
@@ -0,0 +1,4 @@
1
+ export { File, mimeType, name, size } from './file';
2
+ export { FileReadError } from './error';
3
+ export { select, selectMultiple } from './select';
4
+ export { readAsArrayBuffer, readAsDataUrl, readAsText } from './reader';
@@ -0,0 +1,2 @@
1
+ export { File, FileReadError, mimeType, name, readAsArrayBuffer, readAsDataUrl, readAsText, select, selectMultiple, size, } from './index';
2
+ //# sourceMappingURL=public.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../src/file/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,aAAa,EACb,QAAQ,EACR,IAAI,EACJ,iBAAiB,EACjB,aAAa,EACb,UAAU,EACV,MAAM,EACN,cAAc,EACd,IAAI,GACL,MAAM,SAAS,CAAA"}
@@ -0,0 +1 @@
1
+ export { File, FileReadError, mimeType, name, readAsArrayBuffer, readAsDataUrl, readAsText, select, selectMultiple, size, } from './index';
@@ -0,0 +1,56 @@
1
+ import { Effect } from 'effect';
2
+ import { FileReadError } from './error';
3
+ import type { File } from './file';
4
+ /**
5
+ * Reads the file's contents as a UTF-8 string. Mirrors Elm's `File.toString`.
6
+ *
7
+ * Fails with a `FileReadError` if the browser's `FileReader` encounters an
8
+ * error (e.g. the file was deleted while reading). Handle failures with
9
+ * `Effect.catchAll` to convert them into a failure Message.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * ReadResumeText(
14
+ * File.readAsText(file).pipe(
15
+ * Effect.map(text => GotResumeText({ text })),
16
+ * Effect.catchAll(error => Effect.succeed(FailedReadResume({ error: error.reason }))),
17
+ * ),
18
+ * )
19
+ * ```
20
+ */
21
+ export declare const readAsText: (file: File) => Effect.Effect<string, FileReadError>;
22
+ /**
23
+ * Reads the file's contents as a base64-encoded data URL. Useful for rendering
24
+ * image previews without uploading the file first. Mirrors Elm's `File.toUrl`.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * ReadImagePreview(
29
+ * File.readAsDataUrl(imageFile).pipe(
30
+ * Effect.map(dataUrl => GotImagePreview({ dataUrl })),
31
+ * Effect.catchAll(error => Effect.succeed(FailedReadImage({ error: error.reason }))),
32
+ * ),
33
+ * )
34
+ * ```
35
+ */
36
+ export declare const readAsDataUrl: (file: File) => Effect.Effect<string, FileReadError>;
37
+ /**
38
+ * Reads the file's contents as raw binary data. Mirrors Elm's `File.toBytes`.
39
+ *
40
+ * Use this when you need the full binary payload (e.g. to upload, hash, or
41
+ * parse a custom file format). For images you usually want `readAsDataUrl`
42
+ * instead.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * UploadFile(
47
+ * File.readAsArrayBuffer(file).pipe(
48
+ * Effect.flatMap(buffer => uploadToServer(buffer)),
49
+ * Effect.map(() => SucceededUpload()),
50
+ * Effect.catchAll(error => Effect.succeed(FailedUpload({ reason: String(error) }))),
51
+ * ),
52
+ * )
53
+ * ```
54
+ */
55
+ export declare const readAsArrayBuffer: (file: File) => Effect.Effect<ArrayBuffer, FileReadError>;
56
+ //# sourceMappingURL=reader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reader.d.ts","sourceRoot":"","sources":["../../src/file/reader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAE/B,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AACvC,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAoDlC;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,UAAU,GAAI,MAAM,IAAI,KAAG,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAGxE,CAAA;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa,GACxB,MAAM,IAAI,KACT,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAGnC,CAAA;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,iBAAiB,GAC5B,MAAM,IAAI,KACT,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,aAAa,CAGxC,CAAA"}
@@ -0,0 +1,92 @@
1
+ import { Effect } from 'effect';
2
+ import { FileReadError } from './error';
3
+ const readFile = (file, mode, extract) => Effect.async((resume, signal) => {
4
+ const reader = new FileReader();
5
+ const handleLoad = () => {
6
+ const extracted = extract(reader);
7
+ if (extracted === null) {
8
+ resume(Effect.fail(new FileReadError({
9
+ reason: `FileReader returned an unexpected result type for mode "${mode}"`,
10
+ })));
11
+ }
12
+ else {
13
+ resume(Effect.succeed(extracted));
14
+ }
15
+ };
16
+ const handleError = () => {
17
+ const reason = reader.error?.message ?? 'Unknown FileReader error';
18
+ resume(Effect.fail(new FileReadError({ reason })));
19
+ };
20
+ reader.addEventListener('load', handleLoad);
21
+ reader.addEventListener('error', handleError);
22
+ signal.addEventListener('abort', () => {
23
+ reader.abort();
24
+ });
25
+ try {
26
+ if (mode === 'text') {
27
+ reader.readAsText(file);
28
+ }
29
+ else if (mode === 'dataUrl') {
30
+ reader.readAsDataURL(file);
31
+ }
32
+ else {
33
+ reader.readAsArrayBuffer(file);
34
+ }
35
+ }
36
+ catch (thrown) {
37
+ const reason = thrown instanceof Error ? thrown.message : String(thrown);
38
+ resume(Effect.fail(new FileReadError({ reason })));
39
+ }
40
+ });
41
+ /**
42
+ * Reads the file's contents as a UTF-8 string. Mirrors Elm's `File.toString`.
43
+ *
44
+ * Fails with a `FileReadError` if the browser's `FileReader` encounters an
45
+ * error (e.g. the file was deleted while reading). Handle failures with
46
+ * `Effect.catchAll` to convert them into a failure Message.
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * ReadResumeText(
51
+ * File.readAsText(file).pipe(
52
+ * Effect.map(text => GotResumeText({ text })),
53
+ * Effect.catchAll(error => Effect.succeed(FailedReadResume({ error: error.reason }))),
54
+ * ),
55
+ * )
56
+ * ```
57
+ */
58
+ export const readAsText = (file) => readFile(file, 'text', reader => typeof reader.result === 'string' ? reader.result : null);
59
+ /**
60
+ * Reads the file's contents as a base64-encoded data URL. Useful for rendering
61
+ * image previews without uploading the file first. Mirrors Elm's `File.toUrl`.
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * ReadImagePreview(
66
+ * File.readAsDataUrl(imageFile).pipe(
67
+ * Effect.map(dataUrl => GotImagePreview({ dataUrl })),
68
+ * Effect.catchAll(error => Effect.succeed(FailedReadImage({ error: error.reason }))),
69
+ * ),
70
+ * )
71
+ * ```
72
+ */
73
+ export const readAsDataUrl = (file) => readFile(file, 'dataUrl', reader => typeof reader.result === 'string' ? reader.result : null);
74
+ /**
75
+ * Reads the file's contents as raw binary data. Mirrors Elm's `File.toBytes`.
76
+ *
77
+ * Use this when you need the full binary payload (e.g. to upload, hash, or
78
+ * parse a custom file format). For images you usually want `readAsDataUrl`
79
+ * instead.
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * UploadFile(
84
+ * File.readAsArrayBuffer(file).pipe(
85
+ * Effect.flatMap(buffer => uploadToServer(buffer)),
86
+ * Effect.map(() => SucceededUpload()),
87
+ * Effect.catchAll(error => Effect.succeed(FailedUpload({ reason: String(error) }))),
88
+ * ),
89
+ * )
90
+ * ```
91
+ */
92
+ export const readAsArrayBuffer = (file) => readFile(file, 'arrayBuffer', reader => reader.result instanceof ArrayBuffer ? reader.result : null);
@@ -0,0 +1,36 @@
1
+ import { Effect } from 'effect';
2
+ import type { File } from './file';
3
+ /**
4
+ * Opens the native file picker allowing a single file to be selected. Resolves
5
+ * with an array containing the selected file, or an empty array if the user
6
+ * cancelled. Mirrors Elm's `File.Select.file`.
7
+ *
8
+ * The `accept` argument is a list of MIME types or file extensions that
9
+ * restrict what the picker shows. Pass an empty array to allow any file.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * SelectResume(
14
+ * File.select(['application/pdf']).pipe(
15
+ * Effect.map(files => SelectedResume({ files })),
16
+ * ),
17
+ * )
18
+ * ```
19
+ */
20
+ export declare const select: (accept: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<File>>;
21
+ /**
22
+ * Opens the native file picker allowing multiple files to be selected at
23
+ * once. Resolves with the array of selected files, or an empty array if the
24
+ * user cancelled. Mirrors Elm's `File.Select.files`.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * SelectAttachments(
29
+ * File.selectMultiple(['image/*', 'application/pdf']).pipe(
30
+ * Effect.map(files => SelectedAttachments({ files })),
31
+ * ),
32
+ * )
33
+ * ```
34
+ */
35
+ export declare const selectMultiple: (accept: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<File>>;
36
+ //# sourceMappingURL=select.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"select.d.ts","sourceRoot":"","sources":["../../src/file/select.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAAE,MAAM,QAAQ,CAAA;AAEtC,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AA2ClC;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,MAAM,GACjB,QAAQ,aAAa,CAAC,MAAM,CAAC,KAC5B,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,CAA4C,CAAA;AAEhF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,GACzB,QAAQ,aAAa,CAAC,MAAM,CAAC,KAC5B,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,CAA2C,CAAA"}
@@ -0,0 +1,60 @@
1
+ import { Array, Effect } from 'effect';
2
+ const openPicker = ({ accept, multiple, }) => Effect.async((resume, signal) => {
3
+ const input = document.createElement('input');
4
+ input.type = 'file';
5
+ input.accept = accept.join(',');
6
+ input.multiple = multiple;
7
+ input.style.display = 'none';
8
+ const cleanup = () => {
9
+ input.remove();
10
+ };
11
+ const handleChange = () => {
12
+ const files = input.files
13
+ ? Array.fromIterable(input.files)
14
+ : Array.empty();
15
+ cleanup();
16
+ resume(Effect.succeed(files));
17
+ };
18
+ const handleCancel = () => {
19
+ cleanup();
20
+ resume(Effect.succeed(Array.empty()));
21
+ };
22
+ input.addEventListener('change', handleChange);
23
+ input.addEventListener('cancel', handleCancel);
24
+ signal.addEventListener('abort', cleanup);
25
+ document.body.appendChild(input);
26
+ input.click();
27
+ });
28
+ /**
29
+ * Opens the native file picker allowing a single file to be selected. Resolves
30
+ * with an array containing the selected file, or an empty array if the user
31
+ * cancelled. Mirrors Elm's `File.Select.file`.
32
+ *
33
+ * The `accept` argument is a list of MIME types or file extensions that
34
+ * restrict what the picker shows. Pass an empty array to allow any file.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * SelectResume(
39
+ * File.select(['application/pdf']).pipe(
40
+ * Effect.map(files => SelectedResume({ files })),
41
+ * ),
42
+ * )
43
+ * ```
44
+ */
45
+ export const select = (accept) => openPicker({ accept, multiple: false });
46
+ /**
47
+ * Opens the native file picker allowing multiple files to be selected at
48
+ * once. Resolves with the array of selected files, or an empty array if the
49
+ * user cancelled. Mirrors Elm's `File.Select.files`.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * SelectAttachments(
54
+ * File.selectMultiple(['image/*', 'application/pdf']).pipe(
55
+ * Effect.map(files => SelectedAttachments({ files })),
56
+ * ),
57
+ * )
58
+ * ```
59
+ */
60
+ export const selectMultiple = (accept) => openPicker({ accept, multiple: true });
@@ -1,7 +1,18 @@
1
1
  import { Data, Effect, Option } from 'effect';
2
+ import type { File } from '../file';
2
3
  import { Dispatch } from '../runtime';
3
4
  import { VNode } from '../vdom';
4
5
  export { createKeyedLazy, createLazy } from './lazy';
6
+ /**
7
+ * Tag symbol attached to file-aware event handler functions so Scene test
8
+ * helpers can distinguish `OnFileChange` from `OnChange` (both register on
9
+ * the DOM `change` event) and `OnDropFiles` from `OnDrop` (both register on
10
+ * the DOM `drop` event). Internal implementation detail — consumer code
11
+ * should never need to reference this directly.
12
+ */
13
+ export declare const FileHandlerSymbol: unique symbol;
14
+ /** Type-level brand for file-aware event handler tags. */
15
+ export type FileHandlerSymbol = typeof FileHandlerSymbol;
5
16
  /** Modifier key state extracted from a `KeyboardEvent`. */
6
17
  export type KeyboardModifiers = Readonly<{
7
18
  shiftKey: boolean;
@@ -130,6 +141,9 @@ export type Attribute<Message> = Data.TaggedEnum<{
130
141
  OnChange: {
131
142
  readonly f: (value: string) => Message;
132
143
  };
144
+ OnFileChange: {
145
+ readonly f: (files: ReadonlyArray<File>) => Message;
146
+ };
133
147
  OnSubmit: {
134
148
  readonly message: Message;
135
149
  };
@@ -181,6 +195,9 @@ export type Attribute<Message> = Data.TaggedEnum<{
181
195
  OnDrop: {
182
196
  readonly message: Message;
183
197
  };
198
+ OnDropFiles: {
199
+ readonly f: (files: ReadonlyArray<File>) => Message;
200
+ };
184
201
  OnTouchStart: {
185
202
  readonly message: Message;
186
203
  };
@@ -878,6 +895,9 @@ export declare const html: <Message = never>() => {
878
895
  } | {
879
896
  readonly _tag: "OnChange";
880
897
  readonly f: (value: string) => Message;
898
+ } | {
899
+ readonly _tag: "OnFileChange";
900
+ readonly f: (files: ReadonlyArray<File>) => Message;
881
901
  } | {
882
902
  readonly _tag: "OnSubmit";
883
903
  readonly message: Message;
@@ -929,6 +949,9 @@ export declare const html: <Message = never>() => {
929
949
  } | {
930
950
  readonly _tag: "OnDrop";
931
951
  readonly message: Message;
952
+ } | {
953
+ readonly _tag: "OnDropFiles";
954
+ readonly f: (files: ReadonlyArray<File>) => Message;
932
955
  } | {
933
956
  readonly _tag: "OnTouchStart";
934
957
  readonly message: Message;
@@ -1668,6 +1691,10 @@ export declare const html: <Message = never>() => {
1668
1691
  readonly _tag: "OnChange";
1669
1692
  readonly f: (value: string) => Message;
1670
1693
  };
1694
+ OnFileChange: (f: (files: ReadonlyArray<File>) => Message) => {
1695
+ readonly _tag: "OnFileChange";
1696
+ readonly f: (files: ReadonlyArray<File>) => Message;
1697
+ };
1671
1698
  OnSubmit: (message: Message) => {
1672
1699
  readonly _tag: "OnSubmit";
1673
1700
  readonly message: Message;
@@ -1736,6 +1763,10 @@ export declare const html: <Message = never>() => {
1736
1763
  readonly _tag: "OnDrop";
1737
1764
  readonly message: Message;
1738
1765
  };
1766
+ OnDropFiles: (f: (files: ReadonlyArray<File>) => Message) => {
1767
+ readonly _tag: "OnDropFiles";
1768
+ readonly f: (files: ReadonlyArray<File>) => Message;
1769
+ };
1739
1770
  OnTouchStart: (message: Message) => {
1740
1771
  readonly _tag: "OnTouchStart";
1741
1772
  readonly message: Message;