foldkit-form-recipes 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Glenn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,295 @@
1
- # Temporary Holding Version
1
+ # foldkit-form-recipes
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Field recipes for [`foldkit-form`](../form/README.md). A recipe is what one kind
4
+ of key means: a **control**, which reads a draft into the key's value and shows a
5
+ stored value as a draft, and a **display**, which reads the value in a list or a
6
+ detail. Both are made from the same options, so a date reads the same in its
7
+ field and in its table cell, and the parsing that is easy to get quietly wrong
8
+ (a date typed month-first, a time the clocks skip) is written once.
9
+
10
+ ## What it owns
11
+
12
+ | Fact | Owner |
13
+ | --- | --- |
14
+ | The key's schema, and so what a valid value is | your input schema |
15
+ | The drafts, their validity, and the submit | `foldkit-form` |
16
+ | How a draft becomes a value and a value a draft; how a value reads in a list | **this package** |
17
+ | The element that edits it | `foldkit-form-recipes/html` (or your own renderer) |
18
+
19
+ A recipe owns no state and sends no Message, except `image`, whose control
20
+ holds its upload (below). It attaches where any control does: a form's `inputs`, or Entity metadata through `Input.of` and `Display.of`.
21
+
22
+ ## Mental model
23
+
24
+ ```text
25
+ stored value --format--> draft (what the input shows)
26
+ draft (typed) --parse--> value --schema--> valid, or the key's error
27
+ ```
28
+
29
+ `format` runs only when the form is filled, never on a keystroke, so the caret
30
+ and a composition survive typing. A draft `parse` cannot read is the recipe's
31
+ own error (`Enter a date`); an empty one is the form's (`Required`, or nothing
32
+ for an optional key).
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ pnpm add foldkit-form-recipes foldkit-form foldkit-crud
38
+ # and, for the plain renderers:
39
+ pnpm add foldkit-mixins-form
40
+ ```
41
+
42
+ ## Sixty seconds
43
+
44
+ ```ts
45
+ import { Schema } from 'effect'
46
+ import { Form } from 'foldkit-form'
47
+ import { FieldRecipes } from 'foldkit-form-recipes'
48
+ import { renderers } from 'foldkit-form-recipes/html'
49
+ import { FormView } from 'foldkit-mixins-form'
50
+ import { CalendarDate } from 'foldkit/calendar'
51
+
52
+ const due = FieldRecipes.date()
53
+ const startsAt = FieldRecipes.dateTime({ stored: 'instant', zone: 'Europe/London' })
54
+
55
+ const Meeting = Form.make(
56
+ 'Meeting',
57
+ Form.input(Schema.Struct({ due: CalendarDate, startsAt: Schema.String })),
58
+ { inputs: { due: due.control, startsAt: startsAt.control } },
59
+ )
60
+
61
+ const View = FormView.define(Meeting, { renderers: renderers() })
62
+ ```
63
+
64
+ `due.control` and `startsAt.control` are plain `foldkit-form` controls: they do
65
+ no I/O and hold no state. `renderers()` draws them as `input type="date"` and
66
+ `type="datetime-local"`, named by their keys, so a post without scripts carries
67
+ the drafts. Filled with `{ due: Calendar.make(2026, 7, 1), startsAt:
68
+ '2026-07-01T08:30:00.000Z' }`, the inputs read `2026-07-01` and
69
+ `2026-07-01T09:30`, London being an hour ahead in July; typing `2026-07-01T09:30`
70
+ submits `'2026-07-01T08:30:00.000Z'`.
71
+
72
+ ## The recipes
73
+
74
+ ### `date(options?)`
75
+
76
+ A key whose value is a `CalendarDate`: `Calendar.CalendarDate`, or
77
+ `Calendar.CalendarDateFromIsoString` to store it as text. The draft is the
78
+ `YYYY-MM-DD` a date input writes. Anything else, a partly typed date, a day
79
+ first, or 30 February, reads `Enter a date`.
80
+
81
+ ### `dateTime({ stored, zone?, locale? })`
82
+
83
+ A date and a time, stored in one of two forms, chosen explicitly:
84
+
85
+ - **`stored: 'local'`**: the key's value is the wall-clock text a
86
+ `datetime-local` input writes (`2026-10-26T09:30`), in no zone. It is kept as
87
+ typed, so a stored value comes back unchanged. Use it for a time that means a
88
+ clock reading wherever it is read: a shop's opening hour.
89
+ - **`stored: 'instant', zone`**: the value is an ISO instant with an offset, and
90
+ the draft is what the clocks in `zone` (an IANA name such as `Europe/London`)
91
+ read at it. A submitted value is written as `Date.prototype.toISOString` writes
92
+ one; any offset is read. An unknown zone throws when the recipe is made.
93
+
94
+ The zone is a parameter, never the runtime's, so a server and a browser fill the
95
+ same text. To follow the person's own zone, pass it:
96
+ `Intl.DateTimeFormat().resolvedOptions().timeZone`.
97
+
98
+ `locale` (default `en`) is what a display says the value in. It changes no
99
+ draft: the inputs' text is the same in every locale.
100
+
101
+ ### Text-shaped keys
102
+
103
+ Each keeps what was typed, unchanged: none trims, lower-cases or rewrites it.
104
+ Their renderers give the input its type, what the browser may fill it with, and
105
+ turn off capitalization and spelling correction where a correction would be a
106
+ wrong address.
107
+
108
+ | Recipe | Takes | Input |
109
+ | --- | --- | --- |
110
+ | `email({ autocomplete? })` | one `@`, something either side, no space; else `Enter an email address` | `type="email"`, `autocomplete="email"` |
111
+ | `url({ protocols?, autocomplete? })` | an address that parses with a scheme in `protocols` (default `https:`, `http:`), so `javascript:` is refused | `type="url"` |
112
+ | `tel({ autocomplete? })` | anything: a number is too varied to check without refusing a real one, so leave it to the schema | `type="tel"`, `inputmode="tel"` |
113
+ | `password({ purpose })` | anything | `type="password"`, `autocomplete` `current-password` or `new-password` |
114
+ | `slug(from, { prefix?, through? })` | anything | a text input after `prefix` |
115
+
116
+ A `slug` follows the key `from`, made into a slug by `through` (default
117
+ `FieldRecipes.slugify`: lower case, unaccented, `ß` as `ss` and `ı` as `i`, words
118
+ joined by `-`; a script with no Latin spelling, such as `東京`, leaves nothing,
119
+ so such a title's slug is written by hand), until the
120
+ person writes it; a form filled with a stored slug does not follow, so a
121
+ published address does not move because its title did. Its control is a
122
+ `ControlChange`, which only a form's `inputs` takes.
123
+
124
+ A `password` never shows in a list (its display is hidden) and has no reveal
125
+ toggle: whether the text shows is state the form does not hold, and
126
+ `foldkit-mixins-ui`'s `PasswordInput` keeps it in the application. Keep a
127
+ password out of any saved draft: a form cannot leave a key out of `partial()`,
128
+ so that rule is the application's.
129
+
130
+ ### Amounts: `money`, `decimal`, `percent`
131
+
132
+ ```ts
133
+ const price = FieldRecipes.money({ currency: 'EUR', unit: 'minor', locale: 'de-DE' })
134
+ const weight = FieldRecipes.decimal({ scale: 3 })
135
+ const discount = FieldRecipes.percent({ unit: 'fraction' })
136
+ ```
137
+
138
+ An amount is read and shown as `locale` writes it, and built from its digits, so
139
+ no floating point stands between what was typed and what is stored:
140
+
141
+ - **`money({ currency, unit, locale?, negative? })`**: `unit: 'minor'` stores an
142
+ integer of the currency's smallest unit (`123456` for 1.234,56 €), `'decimal'` a
143
+ decimal text (`'1234.56'`); the key's schema says which, and a value of the other
144
+ form fails the fill rather than being converted. The currency sets the decimals:
145
+ none for JPY, three for KWD. A filled field shows `1.234,56 €`, and typing
146
+ `1.234,56` or `1234,56` reads the same.
147
+ - **`decimal({ scale, locale?, negative? })`**: a number with at most `scale`
148
+ decimals.
149
+ - **`percent({ unit, scale?, locale?, negative? })`**: shown as `12.5%`, stored as
150
+ the share (`unit: 'fraction'`, `0.125`) or the points (`'points'`, `12.5`).
151
+
152
+ What is refused rather than guessed at: grouping other than the locale's (`12,3`
153
+ in `en-US` is not `123`, and `12.3` in `de-DE` is not either), more decimals than
154
+ the scale (never rounded), a minus unless `negative: true`, a space inside an
155
+ amount where the locale groups with none, a symbol anywhere but at either end,
156
+ a minor amount past what a JavaScript number holds exactly (use `'decimal'` for
157
+ those), and a `decimal` or `percent` a JavaScript number would round. A partial
158
+ amount (`-`, or `1,2` in `en-US`) reads `Enter an amount`: the form checks a key
159
+ as it changes. What a keyboard types for what a format writes is read alike: `¥`
160
+ for `¥`, `'` for the Swiss `’`, full-width digits and punctuation, and the bidi
161
+ marks an rtl locale writes around an amount are not part of it. Nothing else is
162
+ folded: `10²` and `①②` are refused rather than read as `102` and `12`, and so is
163
+ an amount holding an override or a zero-width space, which would show one
164
+ number and mean another. The inputs are text with a numeric keypad (`inputmode`), since `"4."`
165
+ is a fine draft that `type="number"` would refuse to report.
166
+
167
+ ### Drawn through `foldkit-mixins-ui`
168
+
169
+ `foldkit-form-recipes/ui`'s `renderers({ input? })` draws the same native inputs
170
+ under `foldkit-mixins-ui`'s Input Slot, for a form drawn with
171
+ `foldkit-mixins-form/ui`'s table:
172
+
173
+ ```ts
174
+ import { renderers as uiRecipes } from 'foldkit-form-recipes/ui'
175
+ import { Style } from 'foldkit-mixins'
176
+ import * as UiForm from 'foldkit-mixins-form/ui'
177
+ import { InputSlots, Recipes } from 'foldkit-mixins-ui'
178
+
179
+ const input = Style.forSlots(InputSlots)(Recipes.Input())
180
+ const UiView = FormView.define(Meeting, {
181
+ renderers: { ...UiForm.renderers({ input }), ...uiRecipes({ input }) },
182
+ })
183
+ ```
184
+
185
+ The inputs, their names and their Messages are the plain ones; only the look is
186
+ the Input recipe's. A calendar in a popover is not one of them: it holds an open
187
+ month and a focused day, state a renderer has nowhere to keep, so it would be a
188
+ control backed by a Bundle, as `image` is, and is not built.
189
+
190
+ ### An image: `foldkit-form-recipes/image`
191
+
192
+ `image({ id, url?, headers?, words?, styles? })` is a key whose value is the
193
+ `HashedAsset` that `foldkit-media`'s upload route answers. Its control is a
194
+ Bundle, because an upload has state of its own:
195
+
196
+ ```text
197
+ Empty --choose--> Sending --201--> Kept (the value)
198
+ Sending --cancel--> Empty
199
+ Sending --refused--> Refused --retry--> Sending
200
+ Refused --choose--> Sending
201
+ Sending, Refused, Kept --remove--> Empty
202
+ ```
203
+
204
+ ```ts
205
+ import { image } from 'foldkit-form-recipes/image'
206
+ import { HashedAsset } from 'foldkit-media'
207
+
208
+ const cover = image({ id: 'cover' })
209
+ const Post = Form.make('Post', Form.input(Schema.Struct({ cover: HashedAsset })), {
210
+ inputs: { cover: cover.control },
211
+ })
212
+ ```
213
+
214
+ Choosing a file (dropped, or picked through the drop zone's input) sends it at
215
+ once with `foldkit-media/browser`'s `upload`. The control draws its progress and
216
+ a Cancel button, which ends the send's Subscription and so aborts the request.
217
+ A refusal shows the route's reason (`words.refused`) with Try again, which
218
+ sends the same file again. Only `Kept` is a value: a file on its way, or
219
+ refused, is nothing entered.
220
+
221
+ - **A submit while a file is on its way** waits for it (the control is `busy`
222
+ while `Sending`): the key reads as being checked, the form says
223
+ `words.checking` under it, and the submit goes out with the image once the
224
+ route keeps it. A cancel or a refusal ends the wait with nothing submitted, a
225
+ required key then saying `Required`.
226
+ - **Who may upload** stays the route's `UploadPolicy`, which a page's session
227
+ cookie answers. `headers` is only what the request carries, fixed when the
228
+ recipe is made, so never a secret; a bad header or `url` throws then.
229
+ - **A dropped file that is no image** is refused here, as `Unsupported`, before
230
+ its bytes go anywhere: `accept` filters the picker, not a drop.
231
+ - **For a reader:** the key's label names the group the control is drawn in; the
232
+ status is one live region, said once per state, while the progress moves under
233
+ it; a press that takes its button away moves focus on (Cancel and Remove to
234
+ the file input, Try again to Cancel).
235
+ - **For a stylist:** the root's `data-upload` is the state (`Empty`, `Sending`,
236
+ `Refused`, `Kept`); `styles.field` styles `ImageSlots` and `styles.drop`
237
+ `foldkit-mixins-ui`'s `FileDropSlots`; `words` words it. `id` is the drop
238
+ zone's and its file input's, and where focus is sent, so it is unique on the
239
+ page and an identifier a selector names (`cover`, not `1cover` or `a.b`).
240
+ - **What it needs:** `foldkit-media`, `@foldkit/ui`, `foldkit-mixins` and
241
+ `foldkit-mixins-ui` beside the package's own (`./ui` needs `foldkit-mixins`,
242
+ `foldkit-mixins-ui` and `foldkit-mixins-form`; `./html` `foldkit-mixins-form`).
243
+ - **In `vite dev`,** a Model holding a `File` cannot be kept across a reload, so
244
+ a send on its way is lost by one.
245
+
246
+ ## Displays
247
+
248
+ Each recipe's `display` is a `foldkit-crud` Display, for a list or a detail:
249
+
250
+ ```ts
251
+ Entity.annotateMembers({ due: Display.of(due.display) })
252
+ ```
253
+
254
+ `date({ locale: 'de' }).display` reads `make(2026, 3, 5)` as `05.03.2026`; an
255
+ instant reads in its recipe's zone.
256
+
257
+ ## Failure and edge semantics
258
+
259
+ - **A time the clocks skip is refused.** In `instant` mode, `2026-03-29T01:30` in
260
+ London names no instant (the clocks go from 01:00 to 02:00), and reads
261
+ `Enter a date and a time that exists`, the words for any draft that names no
262
+ time, a partly typed one too. A browser's `new Date` would move it an hour on
263
+ without saying.
264
+ - **A time the clocks repeat reads as the earlier.** `2026-10-25T01:30` in London
265
+ happens twice; it submits `00:30Z`, the first.
266
+ - **A year under 100 is that year.** `0050-06-01` is not read as 1950, as
267
+ `Date.UTC` would.
268
+ - **A fill with a value of the wrong form throws,** naming the form and the key: a
269
+ `CalendarDate` key given a string, local text in an `instant` key. That is a
270
+ wiring mistake, so it fails at once rather than showing nothing. So does a
271
+ recipe made with an option no format can use.
272
+ - **An empty `Schema.String` key submits `''`.** The empty draft is the form's,
273
+ and a plain string schema accepts it. Give a moment that must be set a schema
274
+ that refuses it: `Schema.String.check(Schema.isMinLength(1))`.
275
+ - **An image control's draft is not stored mid-send.** A `File` cannot be
276
+ kept in a stored draft, so `settled` turns `Sending` and `Refused` into
277
+ `Empty`; a kept image stays.
278
+ - **A late answer is ignored.** Every send has its own attempt number, and an
279
+ event from a send cancelled or replaced changes nothing.
280
+
281
+ ## Limits
282
+
283
+ - An email or web address that passes is well-formed, not known to exist: whether
284
+ it receives mail or answers is the server's to find out.
285
+ - No arithmetic across zones and no zone-aware value type: an instant is text,
286
+ and its zone is the recipe's.
287
+ - A date is written and read with a four-digit year, 0000 to 9999: a later year
288
+ is refused, though a date input may take one.
289
+ - `slugify` spells Latin letters only: a title in another script gives an empty
290
+ slug, which is then written by hand.
291
+ - A browser's own date and time inputs are the default renderers; their look and
292
+ their picker are the browser's.
293
+ - `image` is a Bundle control, so it takes no `check` and cannot sit in a row of
294
+ a nested form. There is no `file` recipe: the upload route takes images
295
+ alone.
@@ -0,0 +1,4 @@
1
+ import { Renderers } from "foldkit-mixins-form";
2
+ /** A renderer for each recipe kind. */
3
+ export declare const renderers: <Message>() => Renderers<Message>;
4
+ //# sourceMappingURL=html.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.d.mts","names":[],"sources":["../src/html.ts"],"mappings":";;qBAqEa,YAAa,cAAY,UAAU"}
package/dist/html.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import { t as FieldRecipes } from "./src-CutVZjTd.mjs";
2
+ // src/html.ts
3
+ const { Kinds } = FieldRecipes;
4
+ /** A native input of `type`, holding the draft as typed, with what else the kind asks. */
5
+ const native = (type, extra = () => []) => (context) => {
6
+ const { state, draft, change, blurred, slots, h } = context;
7
+ return h.input(slots.text.attrs([
8
+ ...state,
9
+ h.Type(type),
10
+ h.Value(String(draft)),
11
+ h.OnInput(change),
12
+ h.OnBlur(blurred),
13
+ ...extra(context)
14
+ ]));
15
+ };
16
+ /** What the browser may fill in, and that a keyboard must not capitalize or correct it. */
17
+ const literal = ({ h }, autocomplete) => [
18
+ h.Attribute("autocomplete", autocomplete),
19
+ h.Attribute("autocapitalize", "none"),
20
+ h.Attribute("spellcheck", "false")
21
+ ];
22
+ /** The data of a control of `kind`, which the table only hands it. */
23
+ const dataOf = (kind, control) => {
24
+ if (!kind.is(control)) throw new Error(`a ${control.kind} control under another kind's renderer`);
25
+ return control.data;
26
+ };
27
+ /**
28
+ * An amount's text input: text, since `"4."` and `"-"` are fine drafts that
29
+ * `type="number"` would refuse to report. Its keypad has a minus only when the
30
+ * amount may be negative (`decimal` has none on iOS), and no point for a whole one.
31
+ */
32
+ const amount = (of) => native("text", (context) => {
33
+ const { negative, scale } = of(context.control);
34
+ return [context.h.InputMode(negative === true ? "text" : scale === 0 ? "numeric" : "decimal"), context.h.Attribute("autocomplete", "off")];
35
+ });
36
+ /** A renderer for each recipe kind. */
37
+ const renderers = () => ({
38
+ [Kinds.Date.kind]: native("date"),
39
+ [Kinds.DateTime.kind]: native("datetime-local"),
40
+ [Kinds.Email.kind]: native("email", (context) => literal(context, dataOf(Kinds.Email, context.control).autocomplete ?? "email")),
41
+ [Kinds.Url.kind]: native("url", (context) => literal(context, dataOf(Kinds.Url, context.control).autocomplete ?? "url")),
42
+ [Kinds.Tel.kind]: native("tel", (context) => [context.h.InputMode("tel"), context.h.Attribute("autocomplete", dataOf(Kinds.Tel, context.control).autocomplete ?? "tel")]),
43
+ [Kinds.Password.kind]: native("password", (context) => literal(context, dataOf(Kinds.Password, context.control).purpose === "new" ? "new-password" : "current-password")),
44
+ [Kinds.Money.kind]: amount((control) => dataOf(Kinds.Money, control)),
45
+ [Kinds.Decimal.kind]: amount((control) => dataOf(Kinds.Decimal, control)),
46
+ [Kinds.Percent.kind]: amount((control) => {
47
+ const data = dataOf(Kinds.Percent, control);
48
+ return {
49
+ negative: data.negative,
50
+ scale: data.scale ?? 2
51
+ };
52
+ }),
53
+ [Kinds.Slug.kind]: (context) => context.h.div(context.slots.group.attrs(), [context.h.span(context.slots.affix.attrs(), [dataOf(Kinds.Slug, context.control).prefix]), native("text", (inner) => literal(inner, "off"))(context)])
54
+ });
55
+ export { renderers };
56
+
57
+ //# sourceMappingURL=html.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.mjs","names":[],"sources":["../src/html.ts"],"sourcesContent":["/**\n * The plain renderers of the recipes' kinds, for `foldkit-mixins-form`'s\n * `FormView.define(form, { renderers })`, beside its own: each a native input\n * the browser already knows how to edit, named by its key so a post without\n * scripts carries it.\n */\nimport type { Control } from 'foldkit-form'\nimport type { Renderer, Renderers, RenderContext } from 'foldkit-mixins-form'\nimport type { Attribute } from 'foldkit/html'\nimport { FieldRecipes } from './index.js'\n\nconst { Kinds } = FieldRecipes\n\n/** A native input of `type`, holding the draft as typed, with what else the kind asks. */\nconst native =\n <Message>(\n type: string,\n extra: (context: RenderContext<Message>) => ReadonlyArray<Attribute<Message>> = () => [],\n ): Renderer<Message> =>\n context => {\n const { state, draft, change, blurred, slots, h } = context\n return h.input(\n slots.text.attrs([\n ...state,\n h.Type(type),\n h.Value(String(draft)),\n h.OnInput(change),\n h.OnBlur(blurred),\n ...extra(context),\n ]),\n )\n }\n\n/** What the browser may fill in, and that a keyboard must not capitalize or correct it. */\nconst literal = <Message>(\n { h }: RenderContext<Message>,\n autocomplete: string,\n): ReadonlyArray<Attribute<Message>> => [\n h.Attribute('autocomplete', autocomplete),\n h.Attribute('autocapitalize', 'none'),\n h.Attribute('spellcheck', 'false'),\n]\n\n/** The data of a control of `kind`, which the table only hands it. */\nconst dataOf = <Data>(\n kind: { readonly is: (control: Control) => control is Control<Data> },\n control: Control,\n): Data => {\n if (!kind.is(control)) throw new Error(`a ${control.kind} control under another kind's renderer`)\n return control.data\n}\n\n/**\n * An amount's text input: text, since `\"4.\"` and `\"-\"` are fine drafts that\n * `type=\"number\"` would refuse to report. Its keypad has a minus only when the\n * amount may be negative (`decimal` has none on iOS), and no point for a whole one.\n */\nconst amount = <Message>(\n of: (control: Control) => { readonly negative?: boolean | undefined; readonly scale: number },\n): Renderer<Message> =>\n native('text', context => {\n const { negative, scale } = of(context.control)\n return [\n context.h.InputMode(negative === true ? 'text' : scale === 0 ? 'numeric' : 'decimal'),\n context.h.Attribute('autocomplete', 'off'),\n ]\n })\n\n/** A renderer for each recipe kind. */\nexport const renderers = <Message>(): Renderers<Message> => ({\n [Kinds.Date.kind]: native('date'),\n [Kinds.DateTime.kind]: native('datetime-local'),\n [Kinds.Email.kind]: native('email', context =>\n literal(context, dataOf(Kinds.Email, context.control).autocomplete ?? 'email'),\n ),\n [Kinds.Url.kind]: native('url', context =>\n literal(context, dataOf(Kinds.Url, context.control).autocomplete ?? 'url'),\n ),\n [Kinds.Tel.kind]: native('tel', context => [\n context.h.InputMode('tel'),\n context.h.Attribute('autocomplete', dataOf(Kinds.Tel, context.control).autocomplete ?? 'tel'),\n ]),\n [Kinds.Password.kind]: native('password', context =>\n literal(\n context,\n dataOf(Kinds.Password, context.control).purpose === 'new'\n ? 'new-password'\n : 'current-password',\n ),\n ),\n [Kinds.Money.kind]: amount(control => dataOf(Kinds.Money, control)),\n [Kinds.Decimal.kind]: amount(control => dataOf(Kinds.Decimal, control)),\n [Kinds.Percent.kind]: amount(control => {\n const data = dataOf(Kinds.Percent, control)\n return { negative: data.negative, scale: data.scale ?? 2 }\n }),\n // The address before the slug is text beside its input, in the field's affix.\n [Kinds.Slug.kind]: context =>\n context.h.div(context.slots.group.attrs(), [\n context.h.span(context.slots.affix.attrs(), [dataOf(Kinds.Slug, context.control).prefix]),\n native<Message>('text', inner => literal(inner, 'off'))(context),\n ]),\n})\n"],"mappings":";;AAWA,MAAM,EAAE,UAAU;;AAGlB,MAAM,UAEF,MACA,cAAsF,CAAC,OAEzF,YAAW;CACT,MAAM,EAAE,OAAO,OAAO,QAAQ,SAAS,OAAO,MAAM;CACpD,OAAO,EAAE,MACP,MAAM,KAAK,MAAM;EACf,GAAG;EACH,EAAE,KAAK,IAAI;EACX,EAAE,MAAM,OAAO,KAAK,CAAC;EACrB,EAAE,QAAQ,MAAM;EAChB,EAAE,OAAO,OAAO;EAChB,GAAG,MAAM,OAAO;CAClB,CAAC,CACH;AACF;;AAGF,MAAM,WACJ,EAAE,KACF,iBACsC;CACtC,EAAE,UAAU,gBAAgB,YAAY;CACxC,EAAE,UAAU,kBAAkB,MAAM;CACpC,EAAE,UAAU,cAAc,OAAO;AACnC;;AAGA,MAAM,UACJ,MACA,YACS;CACT,IAAI,CAAC,KAAK,GAAG,OAAO,GAAG,MAAM,IAAI,MAAM,KAAK,QAAQ,KAAK,uCAAuC;CAChG,OAAO,QAAQ;AACjB;;;;;;AAOA,MAAM,UACJ,OAEA,OAAO,SAAQ,YAAW;CACxB,MAAM,EAAE,UAAU,UAAU,GAAG,QAAQ,OAAO;CAC9C,OAAO,CACL,QAAQ,EAAE,UAAU,aAAa,OAAO,SAAS,UAAU,IAAI,YAAY,SAAS,GACpF,QAAQ,EAAE,UAAU,gBAAgB,KAAK,CAC3C;AACF,CAAC;;AAGH,MAAa,mBAAgD;EAC1D,MAAM,KAAK,OAAO,OAAO,MAAM;EAC/B,MAAM,SAAS,OAAO,OAAO,gBAAgB;EAC7C,MAAM,MAAM,OAAO,OAAO,UAAS,YAClC,QAAQ,SAAS,OAAO,MAAM,OAAO,QAAQ,OAAO,CAAC,CAAC,gBAAgB,OAAO,CAC/E;EACC,MAAM,IAAI,OAAO,OAAO,QAAO,YAC9B,QAAQ,SAAS,OAAO,MAAM,KAAK,QAAQ,OAAO,CAAC,CAAC,gBAAgB,KAAK,CAC3E;EACC,MAAM,IAAI,OAAO,OAAO,QAAO,YAAW,CACzC,QAAQ,EAAE,UAAU,KAAK,GACzB,QAAQ,EAAE,UAAU,gBAAgB,OAAO,MAAM,KAAK,QAAQ,OAAO,CAAC,CAAC,gBAAgB,KAAK,CAC9F,CAAC;EACA,MAAM,SAAS,OAAO,OAAO,aAAY,YACxC,QACE,SACA,OAAO,MAAM,UAAU,QAAQ,OAAO,CAAC,CAAC,YAAY,QAChD,iBACA,kBACN,CACF;EACC,MAAM,MAAM,OAAO,QAAO,YAAW,OAAO,MAAM,OAAO,OAAO,CAAC;EACjE,MAAM,QAAQ,OAAO,QAAO,YAAW,OAAO,MAAM,SAAS,OAAO,CAAC;EACrE,MAAM,QAAQ,OAAO,QAAO,YAAW;EACtC,MAAM,OAAO,OAAO,MAAM,SAAS,OAAO;EAC1C,OAAO;GAAE,UAAU,KAAK;GAAU,OAAO,KAAK,SAAS;EAAE;CAC3D,CAAC;EAEA,MAAM,KAAK,QAAO,YACjB,QAAQ,EAAE,IAAI,QAAQ,MAAM,MAAM,MAAM,GAAG,CACzC,QAAQ,EAAE,KAAK,QAAQ,MAAM,MAAM,MAAM,GAAG,CAAC,OAAO,MAAM,MAAM,QAAQ,OAAO,CAAC,CAAC,MAAM,CAAC,GACxF,OAAgB,SAAQ,UAAS,QAAQ,OAAO,KAAK,CAAC,CAAC,CAAC,OAAO,CACjE,CAAC;AACL"}
@@ -0,0 +1,186 @@
1
+ import { FieldRecipe } from "./index.mjs";
2
+ import { Schema } from "effect";
3
+ import { BundleControl } from "foldkit-form";
4
+ import { HashedAsset } from "foldkit-media";
5
+ import { UploadOptions, UploadRefusal } from "foldkit-media/browser";
6
+ import { Capability, NamedStyle, Slot, Slots } from "foldkit-mixins";
7
+ import { FileDropSlots } from "foldkit-mixins-ui";
8
+ /**
9
+ * Where the image stands. `Sending` and `Refused` hold the `File` itself, so a
10
+ * retry sends it again; a stored draft cannot hold one, so `settled` empties them.
11
+ */
12
+ export declare const ImageUpload: import("foldkit/schema").TaggedUnion<{
13
+ readonly Empty: {};
14
+ readonly Sending: {
15
+ file: Schema.instanceOf<File, File>;
16
+ attempt: Schema.Number;
17
+ loaded: Schema.Number;
18
+ total: Schema.Number;
19
+ };
20
+ readonly Refused: {
21
+ file: Schema.instanceOf<File, File>;
22
+ reason: Schema.Literals<readonly ["Forbidden", "Unauthenticated", "TooLarge", "Unsupported", "Malformed", "Network", "Unexpected"]>;
23
+ status: Schema.Number;
24
+ };
25
+ readonly Kept: {
26
+ asset: Schema.refine<HashedAsset, Schema.Struct<{
27
+ readonly key: Schema.String;
28
+ readonly width: Schema.Int;
29
+ readonly height: Schema.Int;
30
+ readonly alpha: Schema.Boolean;
31
+ readonly hash: Schema.OptionFromNullOr<Schema.String>;
32
+ readonly placeholder: Schema.OptionFromNullOr<Schema.TaggedUnion<{
33
+ readonly Color: Schema.TaggedStruct<"Color", {
34
+ readonly value: Schema.String;
35
+ }>;
36
+ readonly LowRes: Schema.TaggedStruct<"LowRes", {}>;
37
+ readonly ThumbHash: Schema.TaggedStruct<"ThumbHash", {
38
+ readonly hash: Schema.String;
39
+ }>;
40
+ }>>;
41
+ readonly focus: Schema.OptionFromOptionalNullOr<Schema.Struct<{
42
+ readonly x: Schema.Finite;
43
+ readonly y: Schema.Finite;
44
+ }>>;
45
+ }>>;
46
+ };
47
+ }>;
48
+ export type ImageUpload = typeof ImageUpload.Type;
49
+ export declare const ImageModel: Schema.Struct<{
50
+ readonly drop: Schema.Struct<{
51
+ readonly id: Schema.String;
52
+ readonly isDragOver: Schema.Boolean;
53
+ }>;
54
+ readonly upload: import("foldkit/schema").TaggedUnion<{
55
+ readonly Empty: {};
56
+ readonly Sending: {
57
+ file: Schema.instanceOf<File, File>;
58
+ attempt: Schema.Number;
59
+ loaded: Schema.Number;
60
+ total: Schema.Number;
61
+ };
62
+ readonly Refused: {
63
+ file: Schema.instanceOf<File, File>;
64
+ reason: Schema.Literals<readonly ["Forbidden", "Unauthenticated", "TooLarge", "Unsupported", "Malformed", "Network", "Unexpected"]>;
65
+ status: Schema.Number;
66
+ };
67
+ readonly Kept: {
68
+ asset: Schema.refine<HashedAsset, Schema.Struct<{
69
+ readonly key: Schema.String;
70
+ readonly width: Schema.Int;
71
+ readonly height: Schema.Int;
72
+ readonly alpha: Schema.Boolean;
73
+ readonly hash: Schema.OptionFromNullOr<Schema.String>;
74
+ readonly placeholder: Schema.OptionFromNullOr<Schema.TaggedUnion<{
75
+ readonly Color: Schema.TaggedStruct<"Color", {
76
+ readonly value: Schema.String;
77
+ }>;
78
+ readonly LowRes: Schema.TaggedStruct<"LowRes", {}>;
79
+ readonly ThumbHash: Schema.TaggedStruct<"ThumbHash", {
80
+ readonly hash: Schema.String;
81
+ }>;
82
+ }>>;
83
+ readonly focus: Schema.OptionFromOptionalNullOr<Schema.Struct<{
84
+ readonly x: Schema.Finite;
85
+ readonly y: Schema.Finite;
86
+ }>>;
87
+ }>>;
88
+ };
89
+ }>;
90
+ /** How many sends were started: each one's events carry its number, so a cancelled one's are ignored. */
91
+ readonly attempts: Schema.Number;
92
+ }>;
93
+ export type ImageModel = typeof ImageModel.Type;
94
+ export declare const ImageMessage: import("foldkit/message").MessageUnion<{
95
+ readonly GotDrop: {
96
+ message: Schema.Union<readonly [Schema.TaggedStruct<"EnteredDragZone", {}>, Schema.TaggedStruct<"LeftDragZone", {}>, Schema.TaggedStruct<"DroppedFiles", {
97
+ readonly files: Schema.NonEmptyArray<Schema.instanceOf<File, File>>;
98
+ }>, Schema.TaggedStruct<"DroppedNonFiles", {}>]>;
99
+ };
100
+ readonly Progressed: {
101
+ attempt: Schema.Number;
102
+ loaded: Schema.Number;
103
+ total: Schema.Number;
104
+ };
105
+ readonly Uploaded: {
106
+ attempt: Schema.Number;
107
+ asset: Schema.refine<HashedAsset, Schema.Struct<{
108
+ readonly key: Schema.String;
109
+ readonly width: Schema.Int;
110
+ readonly height: Schema.Int;
111
+ readonly alpha: Schema.Boolean;
112
+ readonly hash: Schema.OptionFromNullOr<Schema.String>;
113
+ readonly placeholder: Schema.OptionFromNullOr<Schema.TaggedUnion<{
114
+ readonly Color: Schema.TaggedStruct<"Color", {
115
+ readonly value: Schema.String;
116
+ }>;
117
+ readonly LowRes: Schema.TaggedStruct<"LowRes", {}>;
118
+ readonly ThumbHash: Schema.TaggedStruct<"ThumbHash", {
119
+ readonly hash: Schema.String;
120
+ }>;
121
+ }>>;
122
+ readonly focus: Schema.OptionFromOptionalNullOr<Schema.Struct<{
123
+ readonly x: Schema.Finite;
124
+ readonly y: Schema.Finite;
125
+ }>>;
126
+ }>>;
127
+ };
128
+ readonly Failed: {
129
+ attempt: Schema.Number;
130
+ reason: Schema.Literals<readonly ["Forbidden", "Unauthenticated", "TooLarge", "Unsupported", "Malformed", "Network", "Unexpected"]>;
131
+ status: Schema.Number;
132
+ };
133
+ readonly Cancelled: {};
134
+ readonly Retried: {};
135
+ readonly Removed: {};
136
+ /** Focus moved on after a press took away the button pressed. */
137
+ readonly CompletedFocus: {};
138
+ }>;
139
+ export type ImageMessage = typeof ImageMessage.Type;
140
+ /** Every element the control draws, with the drop zone's own two under `FileDropSlots`. */
141
+ export declare const ImageSlots: Slots.Contract<{
142
+ readonly root: Slot.UnnamedSlot<Capability.Capability<"Container", readonly [Capability.Capability<"Interactive", readonly [Capability.Capability<"Base", readonly []>]>]>, readonly Slot.EventName[], readonly Slot.AttributeName[], readonly Slot.RequirementName[], boolean>;
143
+ readonly prompt: Slot.UnnamedSlot<Capability.Capability<"Base", readonly []>, readonly Slot.EventName[], readonly Slot.AttributeName[], readonly Slot.RequirementName[], boolean>;
144
+ readonly status: Slot.UnnamedSlot<Capability.Capability<"Base", readonly []>, readonly Slot.EventName[], readonly Slot.AttributeName[], readonly Slot.RequirementName[], boolean>;
145
+ readonly progress: Slot.UnnamedSlot<Capability.Capability<"Base", readonly []>, readonly Slot.EventName[], readonly Slot.AttributeName[], readonly Slot.RequirementName[], boolean>;
146
+ readonly cancel: Slot.UnnamedSlot<Capability.Capability<"Interactive", readonly [Capability.Capability<"Base", readonly []>]>, readonly Slot.EventName[], readonly Slot.AttributeName[], readonly Slot.RequirementName[], boolean>;
147
+ readonly retry: Slot.UnnamedSlot<Capability.Capability<"Interactive", readonly [Capability.Capability<"Base", readonly []>]>, readonly Slot.EventName[], readonly Slot.AttributeName[], readonly Slot.RequirementName[], boolean>;
148
+ readonly remove: Slot.UnnamedSlot<Capability.Capability<"Interactive", readonly [Capability.Capability<"Base", readonly []>]>, readonly Slot.EventName[], readonly Slot.AttributeName[], readonly Slot.RequirementName[], boolean>;
149
+ }>;
150
+ /**
151
+ * What the control says. `{name}`, `{width}` and `{height}` are filled. Each is
152
+ * said once, in a live region: the progress shows how far a send has gone.
153
+ */
154
+ export interface ImageWords {
155
+ readonly prompt: string;
156
+ readonly sending: string;
157
+ readonly kept: string;
158
+ readonly cancel: string;
159
+ readonly retry: string;
160
+ readonly remove: string;
161
+ /** Why a send was refused, by the route's reason. */
162
+ readonly refused: { readonly [R in UploadRefusal]: string; };
163
+ }
164
+ export declare const imageWords: ImageWords;
165
+ export interface ImageOptions extends UploadOptions {
166
+ /**
167
+ * The drop zone's `id`, which its file input takes: unique on the page, so
168
+ * two image keys never share one.
169
+ */
170
+ readonly id: string;
171
+ readonly words?: Partial<Omit<ImageWords, 'refused'>> & {
172
+ readonly refused?: Partial<ImageWords['refused']> | undefined;
173
+ };
174
+ readonly styles?: {
175
+ readonly field?: NamedStyle<typeof ImageSlots> | undefined;
176
+ readonly drop?: NamedStyle<typeof FileDropSlots> | undefined;
177
+ };
178
+ }
179
+ /**
180
+ * An image key: its value is the `HashedAsset` the upload route kept. The
181
+ * Bundle uploads a file once it is chosen, through `upload` with `url` and
182
+ * `headers`, so who may upload is still the route's `UploadPolicy`. Like every
183
+ * Bundle control it has no `check`, and a row of a nested form cannot hold it.
184
+ */
185
+ export declare const image: (options: ImageOptions) => FieldRecipe<BundleControl<ImageModel, ImageMessage, HashedAsset>>;
186
+ //# sourceMappingURL=image.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image.d.mts","names":[],"sources":["../src/image.ts"],"mappings":";;;;;;;;;;;qBA+Ca,sCAAW;;;IAGpB,MAAI,OAAA,WAAA,MAAA;IACJ,SAAO,OAAA;IACP,QAAM,OAAA;IACN,OAAK,OAAA;;;IAEI,MAAI,OAAA,WAAA,MAAA;IAAc,QAAM,OAAA;IAAiB,QAAM,OAAA;;;IAClD,OAAK,OAAA,OAAA,aAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;YAEH,qBAAqB,YAAY;qBAEhC,YAAU,OAAA;;;;;;;;MAVnB,MAAI,OAAA,WAAA,MAAA;MACJ,SAAO,OAAA;MACP,QAAM,OAAA;MACN,OAAK,OAAA;;;MAEI,MAAI,OAAA,WAAA,MAAA;MAAc,QAAM,OAAA;MAAiB,QAAM,OAAA;;;MAClD,OAAK,OAAA,OAAA,aAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;YAUH,oBAAoB,WAAW;qBAY9B,wCAAY;;IACZ,SAAO,OAAA,gBAAA,OAAA,qCAAA,OAAA,kCAAA,OAAA;;;;;IACJ,SAAO,OAAA;IAAiB,QAAM,OAAA;IAAiB,OAAK,OAAA;;;IACtD,SAAO,OAAA;IAAiB,OAAK,OAAA,OAAA,aAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;IAC/B,SAAO,OAAA;IAAiB,QAAM,OAAA;IAAiB,QAAM,OAAA;;;;;;;;YAOrD,sBAAsB,aAAa;;qBAGlC,YAAU,MAAA;;;;;;;;;;;;;iBAcN;WACN;WACA;WACA;WACA;WACA;WACA;;WAEA,qBAAqB,KAAK;;qBAGxB,YAAY;iBAkBR,qBAAqB;;;;;WAK3B;WACA,QAAQ,QAAQ,KAAK;aACnB,UAAU,QAAQ;;WAEpB;aACE,QAAQ,kBAAkB;aAC1B,OAAO,kBAAkB;;;;;;;;;qBAuOzB,QAAK,SACP,iBACR,YAAY,cAAc,YAAY,cAAc"}