@typed/ui 1.0.0-beta.1 → 1.0.0-beta.11

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -10
  3. package/dist/Alert.d.ts +72 -0
  4. package/dist/Alert.d.ts.map +1 -0
  5. package/dist/Alert.js +40 -0
  6. package/dist/Button.d.ts +100 -0
  7. package/dist/Button.d.ts.map +1 -0
  8. package/dist/Button.js +42 -0
  9. package/dist/Carousel.d.ts +346 -0
  10. package/dist/Carousel.d.ts.map +1 -0
  11. package/dist/Carousel.js +264 -0
  12. package/dist/Checkbox.d.ts +168 -0
  13. package/dist/Checkbox.d.ts.map +1 -0
  14. package/dist/Checkbox.js +146 -0
  15. package/dist/Collection.d.ts +254 -0
  16. package/dist/Collection.d.ts.map +1 -0
  17. package/dist/Collection.js +218 -0
  18. package/dist/Combobox.d.ts +367 -0
  19. package/dist/Combobox.d.ts.map +1 -0
  20. package/dist/Combobox.js +300 -0
  21. package/dist/Composite.d.ts +823 -0
  22. package/dist/Composite.d.ts.map +1 -0
  23. package/dist/Composite.js +615 -0
  24. package/dist/Dialog.d.ts +544 -0
  25. package/dist/Dialog.d.ts.map +1 -0
  26. package/dist/Dialog.js +357 -0
  27. package/dist/Disclosure.d.ts +219 -0
  28. package/dist/Disclosure.d.ts.map +1 -0
  29. package/dist/Disclosure.js +128 -0
  30. package/dist/Dom/Events.d.ts +122 -0
  31. package/dist/Dom/Events.d.ts.map +1 -0
  32. package/dist/Dom/Events.js +192 -0
  33. package/dist/Dom/Props.d.ts +161 -0
  34. package/dist/Dom/Props.d.ts.map +1 -0
  35. package/dist/Dom/Props.js +110 -0
  36. package/dist/Dom/Refs.d.ts +58 -0
  37. package/dist/Dom/Refs.d.ts.map +1 -0
  38. package/dist/Dom/Refs.js +61 -0
  39. package/dist/Dom/Render.d.ts +59 -0
  40. package/dist/Dom/Render.d.ts.map +1 -0
  41. package/dist/Dom/Render.js +71 -0
  42. package/dist/Dom/Types.d.ts +570 -0
  43. package/dist/Dom/Types.d.ts.map +1 -0
  44. package/dist/Dom/Types.js +1 -0
  45. package/dist/Dom/index.d.ts +20 -0
  46. package/dist/Dom/index.d.ts.map +1 -0
  47. package/dist/Dom/index.js +8 -0
  48. package/dist/Dom.d.ts +14 -0
  49. package/dist/Dom.d.ts.map +1 -0
  50. package/dist/Dom.js +13 -0
  51. package/dist/Focusable.d.ts +85 -0
  52. package/dist/Focusable.d.ts.map +1 -0
  53. package/dist/Focusable.js +35 -0
  54. package/dist/Form.d.ts +1727 -0
  55. package/dist/Form.d.ts.map +1 -0
  56. package/dist/Form.js +1142 -0
  57. package/dist/Grid.d.ts +388 -0
  58. package/dist/Grid.d.ts.map +1 -0
  59. package/dist/Grid.js +284 -0
  60. package/dist/Group.d.ts +128 -0
  61. package/dist/Group.d.ts.map +1 -0
  62. package/dist/Group.js +71 -0
  63. package/dist/Heading.d.ts +87 -0
  64. package/dist/Heading.d.ts.map +1 -0
  65. package/dist/Heading.js +58 -0
  66. package/dist/Hovercard.d.ts +297 -0
  67. package/dist/Hovercard.d.ts.map +1 -0
  68. package/dist/Hovercard.js +188 -0
  69. package/dist/HttpRouter.d.ts +129 -6
  70. package/dist/HttpRouter.d.ts.map +1 -1
  71. package/dist/HttpRouter.js +198 -55
  72. package/dist/Link.d.ts +63 -28
  73. package/dist/Link.d.ts.map +1 -1
  74. package/dist/Link.js +84 -37
  75. package/dist/Listbox.d.ts +305 -0
  76. package/dist/Listbox.d.ts.map +1 -0
  77. package/dist/Listbox.js +245 -0
  78. package/dist/Menu.d.ts +663 -0
  79. package/dist/Menu.d.ts.map +1 -0
  80. package/dist/Menu.js +569 -0
  81. package/dist/Menubar.d.ts +249 -0
  82. package/dist/Menubar.d.ts.map +1 -0
  83. package/dist/Menubar.js +207 -0
  84. package/dist/Meter.d.ts +157 -0
  85. package/dist/Meter.d.ts.map +1 -0
  86. package/dist/Meter.js +87 -0
  87. package/dist/NativeDetails.d.ts +41 -0
  88. package/dist/NativeDetails.d.ts.map +1 -0
  89. package/dist/NativeDetails.js +40 -0
  90. package/dist/NativeDialog.d.ts +66 -0
  91. package/dist/NativeDialog.d.ts.map +1 -0
  92. package/dist/NativeDialog.js +88 -0
  93. package/dist/NativePopover.d.ts +43 -0
  94. package/dist/NativePopover.d.ts.map +1 -0
  95. package/dist/NativePopover.js +84 -0
  96. package/dist/Popover.d.ts +240 -0
  97. package/dist/Popover.d.ts.map +1 -0
  98. package/dist/Popover.js +140 -0
  99. package/dist/RadioGroup.d.ts +330 -0
  100. package/dist/RadioGroup.d.ts.map +1 -0
  101. package/dist/RadioGroup.js +241 -0
  102. package/dist/Role.d.ts +64 -0
  103. package/dist/Role.d.ts.map +1 -0
  104. package/dist/Role.js +27 -0
  105. package/dist/Select.d.ts +418 -0
  106. package/dist/Select.d.ts.map +1 -0
  107. package/dist/Select.js +357 -0
  108. package/dist/Separator.d.ts +58 -0
  109. package/dist/Separator.d.ts.map +1 -0
  110. package/dist/Separator.js +32 -0
  111. package/dist/Slider.d.ts +141 -0
  112. package/dist/Slider.d.ts.map +1 -0
  113. package/dist/Slider.js +101 -0
  114. package/dist/SpinButton.d.ts +141 -0
  115. package/dist/SpinButton.d.ts.map +1 -0
  116. package/dist/SpinButton.js +101 -0
  117. package/dist/Storybook.d.ts +76 -0
  118. package/dist/Storybook.d.ts.map +1 -0
  119. package/dist/Storybook.js +102 -0
  120. package/dist/Switch.d.ts +148 -0
  121. package/dist/Switch.d.ts.map +1 -0
  122. package/dist/Switch.js +110 -0
  123. package/dist/Tab.d.ts +26 -0
  124. package/dist/Tab.d.ts.map +1 -0
  125. package/dist/Tab.js +25 -0
  126. package/dist/Tabs.d.ts +411 -0
  127. package/dist/Tabs.d.ts.map +1 -0
  128. package/dist/Tabs.js +262 -0
  129. package/dist/Toolbar.d.ts +248 -0
  130. package/dist/Toolbar.d.ts.map +1 -0
  131. package/dist/Toolbar.js +187 -0
  132. package/dist/Tooltip.d.ts +296 -0
  133. package/dist/Tooltip.d.ts.map +1 -0
  134. package/dist/Tooltip.js +172 -0
  135. package/dist/Tree.d.ts +405 -0
  136. package/dist/Tree.d.ts.map +1 -0
  137. package/dist/Tree.js +333 -0
  138. package/dist/TreeGrid.d.ts +426 -0
  139. package/dist/TreeGrid.d.ts.map +1 -0
  140. package/dist/TreeGrid.js +308 -0
  141. package/dist/VisuallyHidden.d.ts +68 -0
  142. package/dist/VisuallyHidden.d.ts.map +1 -0
  143. package/dist/VisuallyHidden.js +44 -0
  144. package/dist/WindowSplitter.d.ts +336 -0
  145. package/dist/WindowSplitter.d.ts.map +1 -0
  146. package/dist/WindowSplitter.js +305 -0
  147. package/dist/index.d.ts +48 -0
  148. package/dist/index.d.ts.map +1 -1
  149. package/dist/index.js +48 -0
  150. package/package.json +48 -20
  151. package/src/HttpRouter.test.ts +0 -294
  152. package/src/HttpRouter.ts +0 -168
  153. package/src/Link.test.ts +0 -84
  154. package/src/Link.ts +0 -107
  155. package/src/index.ts +0 -2
package/dist/Form.js ADDED
@@ -0,0 +1,1142 @@
1
+ /**
2
+ * Schema-bound native controls, decoded values, field errors, and submit lifetime.
3
+ * Start with make for application forms; explicit-state controls support library boundaries.
4
+ * Browser FormData conversion and structured input codecs are separate APIs.
5
+ *
6
+ * Read the [Form guide](/explore/ui-form) for a complete example.
7
+ *
8
+ * [Platform reference](https://html.spec.whatwg.org/multipage/forms.html#the-form-element).
9
+ * @since 1.0.0
10
+ * @category Overview
11
+ * @packageDocumentation
12
+ */
13
+ import * as Effect from "effect/Effect";
14
+ import * as Context from "effect/Context";
15
+ import * as Schema from "effect/Schema";
16
+ import * as SchemaIssue from "effect/SchemaIssue";
17
+ import * as SchemaTransformation from "effect/SchemaTransformation";
18
+ import { Fx as FxApi, RefSubject, Subject } from "@typed/fx";
19
+ import * as Scope from "effect/Scope";
20
+ import { EventHandler, html, } from "@typed/template";
21
+ import * as Dom from "./Dom.js";
22
+ let nextFormId = 0;
23
+ /**
24
+ * Effect context service used by schema-bound form controls.
25
+ *
26
+ * @remarks
27
+ * Bound controls avoid threading `state` through every call while their
28
+ * service requirement remains visible in the Fx type.
29
+ *
30
+ * `Form` provides the service for its child render lifetime; use outside that
31
+ * boundary fails with the ordinary Effect missing-service defect.
32
+ * @since 1.0.0
33
+ * @category Form context
34
+ */
35
+ export const CurrentForm = Context.Service("@typed/ui/Form/CurrentForm");
36
+ function withCurrentForm() {
37
+ return (f) => FxApi.gen(function* () {
38
+ const current = yield* CurrentForm;
39
+ return f(current.state);
40
+ });
41
+ }
42
+ const FieldMetaSchema = Schema.Struct({
43
+ dirty: Schema.Boolean,
44
+ touched: Schema.Boolean,
45
+ });
46
+ function optionalFields(fields, value) {
47
+ return Object.fromEntries(Object.keys(fields).map((key) => [key, Schema.optionalKey(value)]));
48
+ }
49
+ function emptyOptionalFields() {
50
+ return {};
51
+ }
52
+ /**
53
+ * Builds the serializable schema for a form's hydrated state.
54
+ *
55
+ * @remarks
56
+ * The hydration payload needs validation independent from runtime-only field codecs.
57
+ *
58
+ * Pure schema construction; the returned Schema acquires no Scope or subscription.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * import { StateSchema } from "@typed/ui/Form"
63
+ * import { Schema } from "effect"
64
+ *
65
+ * const codec = Schema.Struct({ email: Schema.String })
66
+ * const stateCodec = StateSchema(codec)
67
+ * ```
68
+ * @since 1.0.0
69
+ * @category Hydration schemas
70
+ */
71
+ export function StateSchema(codec) {
72
+ return Schema.Struct({
73
+ values: codec,
74
+ defaultValues: codec,
75
+ errors: Schema.Struct(optionalFields(codec.fields, Schema.String)),
76
+ meta: Schema.Struct(optionalFields(codec.fields, FieldMetaSchema)),
77
+ submitting: Schema.Boolean,
78
+ });
79
+ }
80
+ /**
81
+ * Creates a Scope-owned hydrated form RefSubject from a Struct codec.
82
+ *
83
+ * @remarks
84
+ * One constructor establishes values, defaults, validation state, field codecs,
85
+ * and hydration identity consistently.
86
+ *
87
+ * Requires `Scope.Scope`. Provide an explicit `id` during SSR; the counter-based
88
+ * fallback is process/order dependent. Only state data hydrates—`codec` and
89
+ * `fields` are reattached from the live Struct on each runtime.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * import { makeState } from "@typed/ui/Form"
94
+ * import { Effect, Schema } from "effect"
95
+ *
96
+ * const codec = Schema.Struct({ email: Schema.String })
97
+ * const program = Effect.gen(function* () {
98
+ * const state = yield* makeState(codec, { id: "signup", values: { email: "" } })
99
+ * return state
100
+ * })
101
+ * ```
102
+ * @since 1.0.0
103
+ * @category State construction
104
+ */
105
+ export function makeState(codec, initial) {
106
+ const id = initial.id ?? `typed-form-${++nextFormId}`;
107
+ return Effect.map(RefSubject.hydrate(StateSchema(codec), {
108
+ values: initial.values,
109
+ defaultValues: initial.defaultValues ?? initial.values,
110
+ errors: initial.errors ?? emptyOptionalFields(),
111
+ meta: initial.meta ?? emptyOptionalFields(),
112
+ submitting: initial.submitting ?? false,
113
+ }), (state) => Object.assign(state, { codec, fields: codec.fields, id }));
114
+ }
115
+ function setFieldError(state, key, error) {
116
+ return RefSubject.update(state, (current) => {
117
+ const errors = { ...current.errors };
118
+ if (error === undefined)
119
+ delete errors[key];
120
+ else
121
+ errors[key] = error;
122
+ return { ...current, errors };
123
+ });
124
+ }
125
+ function fieldErrorId(state, name) {
126
+ return `${state.id}-${encodeURIComponent(name)}-error`;
127
+ }
128
+ function decodeField(state, name, codec, value) {
129
+ return Schema.decodeEffect(codec)(value).pipe(Effect.flatMap((decoded) => Effect.andThen(updateDecodedValue(state, name, decoded), () => setFieldError(state, name, undefined))), Effect.catch((error) => setFieldError(state, name, error.message)));
130
+ }
131
+ function decodeUpdatedField(state, name, encoded) {
132
+ return Schema.decodeUnknownEffect(state.fields[name])(encoded).pipe(Effect.flatMap((decoded) => Effect.andThen(updateDecodedValue(state, name, decoded), () => setFieldError(state, name, undefined))), Effect.catch((error) => setFieldError(state, name, error.message)));
133
+ }
134
+ function inputProps(options, type, codec) {
135
+ const parts = maskParts.get(codec);
136
+ if (parts !== undefined)
137
+ return maskedInputProps(options, type, codec, parts);
138
+ return () => ({
139
+ type,
140
+ name: options.name,
141
+ "aria-describedby": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined
142
+ ? undefined
143
+ : fieldErrorId(options.state, options.name)),
144
+ "aria-invalid": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined ? undefined : true),
145
+ ".value": RefSubject.mapEffect(options.state, (state) => Schema.encodeUnknownEffect(codec)(state.values[options.name])),
146
+ oninput: EventHandler.make(Effect.fn((event) => decodeField(options.state, options.name, codec, Dom.currentTarget(event).value))),
147
+ });
148
+ }
149
+ function renderInput(options, host, type, defaultCodec) {
150
+ const codec = options.codec ?? defaultCodec;
151
+ return FxApi.suspend(() => Dom.renderHost()(options, host, inputProps(options, type, codec), "", (props) => html `<input ...${props} />`));
152
+ }
153
+ function makeInput(type, codec) {
154
+ return function (options, host) {
155
+ return renderInput(options, host, type, codec);
156
+ };
157
+ }
158
+ function makeSchemaBoundInput(formCodec, type) {
159
+ return function (options, host) {
160
+ return withCurrentForm()((state) => {
161
+ const inputOptions = { ...options, state };
162
+ const fieldCodec = formCodec.fields[options.name];
163
+ return renderInput(inputOptions, host, type, fieldCodec);
164
+ });
165
+ };
166
+ }
167
+ /**
168
+ * Binds a native `input[type=text]` to a string field.
169
+ *
170
+ * @remarks
171
+ * The control uses the browser's real input event and a Schema codec while
172
+ * keeping state independently testable.
173
+ *
174
+ * The control Scope owns DOM listeners/subscriptions; the supplied `FormState`
175
+ * may outlive the rendered input. A custom host must apply all merged props.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * import { TextInput, makeState } from "@typed/ui/Form"
180
+ * import { Effect, Schema } from "effect"
181
+ *
182
+ * const codec = Schema.Struct({ name: Schema.String })
183
+ * const input = Effect.gen(function* () {
184
+ * const state = yield* makeState(codec, { values: { name: "" } })
185
+ * return TextInput({ state, name: "name" })
186
+ * })
187
+ * ```
188
+ * @since 1.0.0
189
+ * @category Native controls
190
+ */
191
+ export const TextInput = makeInput("text", Schema.String);
192
+ /**
193
+ * Binds a native search input to a string field.
194
+ * @remarks
195
+ * Preserves the platform's search-input semantics while sharing Typed validation.
196
+ * @since 1.0.0
197
+ * @category Native controls
198
+ */
199
+ export const SearchInput = makeInput("search", Schema.String);
200
+ /**
201
+ * Binds a native email input to a string field.
202
+ * @remarks
203
+ * Keeps browser email affordances and constraints available alongside Schema validation.
204
+ * @since 1.0.0
205
+ * @category Native controls
206
+ */
207
+ export const EmailInput = makeInput("email", Schema.String);
208
+ /**
209
+ * Binds a native URL input to a string field.
210
+ * @remarks
211
+ * Keeps browser URL affordances while the schema remains the decoded state contract.
212
+ * @since 1.0.0
213
+ * @category Native controls
214
+ */
215
+ export const UrlInput = makeInput("url", Schema.String);
216
+ /**
217
+ * Binds a native telephone input to a string field.
218
+ * @remarks
219
+ * Preserves platform telephone keyboards and autocomplete behavior.
220
+ * @since 1.0.0
221
+ * @category Native controls
222
+ */
223
+ export const TelInput = makeInput("tel", Schema.String);
224
+ /**
225
+ * Binds a native password input to a string field.
226
+ * @remarks
227
+ * Uses browser password handling instead of recreating sensitive-input behavior.
228
+ * @since 1.0.0
229
+ * @category Native controls
230
+ */
231
+ export const PasswordInput = makeInput("password", Schema.String);
232
+ /**
233
+ * Binds a native hidden input to a string field.
234
+ * @remarks
235
+ * Allows standards-based form serialization for non-visible values.
236
+ * @since 1.0.0
237
+ * @category Native controls
238
+ */
239
+ export const HiddenInput = makeInput("hidden", Schema.String);
240
+ /**
241
+ * Binds a native color input to a string field.
242
+ * @remarks
243
+ * Retains the browser's color picker while state receives its string value.
244
+ * @since 1.0.0
245
+ * @category Native controls
246
+ */
247
+ export const ColorInput = makeInput("color", Schema.String);
248
+ /**
249
+ * Binds a native time input to a string field.
250
+ * @remarks
251
+ * Preserves browser locale and time-entry behavior without inventing a picker.
252
+ * @since 1.0.0
253
+ * @category Native controls
254
+ */
255
+ export const TimeInput = makeInput("time", Schema.String);
256
+ /**
257
+ * Binds a native local date-time input to a string field.
258
+ * @remarks
259
+ * Keeps the platform's local date-time UI and its standard encoded value.
260
+ * @since 1.0.0
261
+ * @category Native controls
262
+ */
263
+ export const DateTimeLocalInput = makeInput("datetime-local", Schema.String);
264
+ /**
265
+ * Binds a native month input to a string field.
266
+ * @remarks
267
+ * Preserves the browser month picker and standardized string encoding.
268
+ * @since 1.0.0
269
+ * @category Native controls
270
+ */
271
+ export const MonthInput = makeInput("month", Schema.String);
272
+ /**
273
+ * Binds a native week input to a string field.
274
+ * @remarks
275
+ * Preserves platform week-entry behavior and standardized string encoding.
276
+ * @since 1.0.0
277
+ * @category Native controls
278
+ */
279
+ export const WeekInput = makeInput("week", Schema.String);
280
+ /**
281
+ * Binds a native number input to a finite number field.
282
+ * @remarks
283
+ * `FiniteFromString` makes the browser's string value an explicit typed decode.
284
+ * @since 1.0.0
285
+ * @category Native controls
286
+ */
287
+ export const NumberInput = makeInput("number", Schema.FiniteFromString);
288
+ /**
289
+ * Binds a native range input to a finite number field.
290
+ * @remarks
291
+ * Retains native slider interaction while exposing a decoded numeric value.
292
+ * @since 1.0.0
293
+ * @category Native controls
294
+ */
295
+ export const RangeInput = makeInput("range", Schema.FiniteFromString);
296
+ /**
297
+ * Binds a native date input to a `Date` field.
298
+ * @remarks
299
+ * `DateFromString` makes the native encoded value's conversion explicit and fallible.
300
+ * @since 1.0.0
301
+ * @category Native controls
302
+ */
303
+ export const DateInput = makeInput("date", Schema.DateFromString);
304
+ /**
305
+ * Converts native form data to a record, preserving repeated names as arrays.
306
+ * @example
307
+ * ```ts
308
+ * import { formDataToRecord } from "@typed/ui/Form"
309
+ *
310
+ * const data = new FormData()
311
+ * data.append("tag", "one")
312
+ * data.append("tag", "two")
313
+ * const record = formDataToRecord(data)
314
+ * ```
315
+ * @since 1.0.0
316
+ * @category Browser form data
317
+ */
318
+ export function formDataToRecord(data) {
319
+ const result = {};
320
+ for (const [name, value] of data.entries()) {
321
+ const existing = result[name];
322
+ result[name] =
323
+ existing === undefined
324
+ ? value
325
+ : Array.isArray(existing)
326
+ ? [...existing, value]
327
+ : [existing, value];
328
+ }
329
+ return result;
330
+ }
331
+ /**
332
+ * Decodes native FormData through an Effect Schema codec.
333
+ * @remarks
334
+ * Browser serialization, repeated values, Files, and typed validation meet at
335
+ * one explicit fallible boundary.
336
+ * The returned Effect is lazy and owns no browser resource; it references File
337
+ * objects present in the supplied FormData.
338
+ * @example
339
+ * ```ts
340
+ * import { decodeFormData } from "@typed/ui/Form"
341
+ * import { Schema } from "effect"
342
+ *
343
+ * const decode = decodeFormData(Schema.Struct({ name: Schema.String }), new FormData())
344
+ * ```
345
+ * @since 1.0.0
346
+ * @category Browser form data
347
+ */
348
+ export function decodeFormData(codec, data) {
349
+ return Schema.decodeEffect(codec)(formDataToRecord(data));
350
+ }
351
+ /**
352
+ * Checks retained decoded values against the form codec Type.
353
+ *
354
+ * @remarks
355
+ * Success replaces values and clears errors. Failure copies the aggregate schema message across
356
+ * fields and re-fails with SchemaError; this is not per-field issue-path mapping. A prior input
357
+ * decode error does not independently block success when the retained decoded value still
358
+ * validates.
359
+ * @since 1.0.0
360
+ * @category Validation
361
+ */
362
+ export function validate(state) {
363
+ return Effect.flatMap(state, (current) => Schema.decodeUnknownEffect(Schema.toType(state.codec))(current.values)).pipe(Effect.flatMap((values) => Effect.as(RefSubject.update(state, (current) => ({
364
+ ...current,
365
+ values,
366
+ errors: {},
367
+ })), values)), Effect.catch((error) => RefSubject.update(state, (current) => ({
368
+ ...current,
369
+ errors: formErrors(current.values, current.errors, error.message),
370
+ })).pipe(Effect.andThen(Effect.fail(error)))));
371
+ }
372
+ function formErrors(values, errors, error) {
373
+ const next = { ...errors };
374
+ for (const key of Object.keys(values))
375
+ Reflect.set(next, key, error);
376
+ return next;
377
+ }
378
+ /**
379
+ * Creates a named, Schema-decoded mask slot.
380
+ * @remarks
381
+ * Length and character constraints are expressed beside the codec that owns conversion.
382
+ * Pure constructor; the returned descriptor retains the codec but acquires no Scope.
383
+ * @example
384
+ * ```ts
385
+ * import { slot } from "@typed/ui/Form"
386
+ * import { Schema } from "effect"
387
+ *
388
+ * const areaCode = slot("area", Schema.String, { length: 3, charset: /[0-9]/ })
389
+ * ```
390
+ * @since 1.0.0
391
+ * @category Input codecs
392
+ */
393
+ export function slot(name, codec, options = {}) {
394
+ return { _tag: "MaskSlot", name, codec, ...options };
395
+ }
396
+ const maskParts = new WeakMap();
397
+ const maskedInputResets = new WeakMap();
398
+ /**
399
+ * Builds a bidirectional Schema codec from literal text and named slots.
400
+ * @remarks
401
+ * Strict encoding and decoding share the same parts and reject invalid length,
402
+ * characters, literals, or slot values. MaskedInput also uses these parts to
403
+ * retain drafts and format fixed-width slots with explicit charsets. Literal
404
+ * characters must be distinguishable from editable characters for auto-formatting;
405
+ * ambiguous or variable-width masks retain ordinary strict text entry.
406
+ * @example
407
+ * ```ts
408
+ * import { mask, slot } from "@typed/ui/Form"
409
+ * import { Schema } from "effect"
410
+ *
411
+ * const phone = mask("(", slot("area", Schema.String, { length: 3 }), ") ",
412
+ * slot("number", Schema.String, { length: 7 }))
413
+ * ```
414
+ * @since 1.0.0
415
+ * @category Input codecs
416
+ */
417
+ export function mask(...parts) {
418
+ const valueSchema = Schema.declare((value) => typeof value === "object" &&
419
+ value !== null &&
420
+ parts.every((part) => typeof part === "string" || Reflect.has(value, part.name)));
421
+ const codec = Schema.String.pipe(Schema.decodeTo(valueSchema, SchemaTransformation.transformEffect({
422
+ decode: (display, options) => decodeMask(parts, display, options),
423
+ encode: (value, options) => encodeMask(parts, value, options),
424
+ })));
425
+ maskParts.set(codec, parts);
426
+ return codec;
427
+ }
428
+ function maskedInputProps(options, type, codec, parts) {
429
+ return () => {
430
+ let draft;
431
+ let lastValue;
432
+ let composing = false;
433
+ let invalidDraft = false;
434
+ let editRevision = 0;
435
+ let pending;
436
+ let input;
437
+ const format = maskDraftFormatter(parts);
438
+ const message = `Enter a complete value in the format ${parts
439
+ .map((part) => (typeof part === "string" ? part : "_".repeat(part.length ?? 3)))
440
+ .join("")}.`;
441
+ const commit = Effect.fn(function* (element) {
442
+ const revision = ++editRevision;
443
+ const snapshot = yield* options.state;
444
+ pending = snapshot;
445
+ input = element;
446
+ const formatted = format?.(element.value, element.selectionStart ?? element.value.length);
447
+ if (formatted !== undefined) {
448
+ element.value = formatted.value;
449
+ element.setSelectionRange(formatted.cursor, formatted.cursor);
450
+ }
451
+ draft = element.value;
452
+ return yield* Schema.decodeEffect(codec)(draft).pipe(Effect.matchEffect({
453
+ onSuccess: Effect.fn(function* (decoded) {
454
+ if (revision !== editRevision ||
455
+ (yield* options.state).values[options.name] !== snapshot.values[options.name])
456
+ return;
457
+ pending = undefined;
458
+ lastValue = decoded;
459
+ invalidDraft = false;
460
+ element.setCustomValidity("");
461
+ yield* updateDecodedValue(options.state, options.name, decoded);
462
+ yield* setFieldError(options.state, options.name, undefined);
463
+ }),
464
+ onFailure: Effect.fn(function* () {
465
+ if (revision !== editRevision ||
466
+ (yield* options.state).values[options.name] !== snapshot.values[options.name])
467
+ return;
468
+ pending = undefined;
469
+ invalidDraft = true;
470
+ element.setCustomValidity(message);
471
+ yield* setFieldError(options.state, options.name, message);
472
+ }),
473
+ }));
474
+ });
475
+ return {
476
+ type,
477
+ name: options.name,
478
+ "aria-describedby": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined
479
+ ? undefined
480
+ : fieldErrorId(options.state, options.name)),
481
+ "aria-invalid": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined ? undefined : true),
482
+ ".value": Effect.flatMap(options.state, (state) => Schema.encodeUnknownEffect(codec)(state.values[options.name])),
483
+ ref: Effect.fn(function* (element) {
484
+ input = element;
485
+ const initial = yield* options.state;
486
+ lastValue = initial.values[options.name];
487
+ const resetInput = Effect.gen(function* () {
488
+ const revision = ++editRevision;
489
+ pending = undefined;
490
+ draft = undefined;
491
+ invalidDraft = false;
492
+ composing = false;
493
+ const value = (yield* options.state).values[options.name];
494
+ lastValue = value;
495
+ element.setCustomValidity("");
496
+ const encoded = yield* Schema.encodeUnknownEffect(codec)(value);
497
+ if (revision === editRevision && (yield* options.state).values[options.name] === value) {
498
+ element.value = encoded;
499
+ }
500
+ });
501
+ let resets = maskedInputResets.get(options.state);
502
+ if (resets === undefined) {
503
+ resets = { signal: Subject.unsafeMake(), users: 0 };
504
+ maskedInputResets.set(options.state, resets);
505
+ }
506
+ const resetSignal = resets;
507
+ resetSignal.users++;
508
+ yield* Scope.addFinalizer(yield* Effect.scope, Effect.suspend(() => {
509
+ if (--resetSignal.users > 0)
510
+ return Effect.void;
511
+ maskedInputResets.delete(options.state);
512
+ return resetSignal.signal.interrupt;
513
+ }));
514
+ yield* Effect.forkScoped(FxApi.observe(resetSignal.signal, () => resetInput));
515
+ yield* Effect.forkScoped(FxApi.observe(options.state, Effect.fn(function* (state) {
516
+ if (pending !== undefined &&
517
+ state.values[options.name] !== pending.values[options.name]) {
518
+ pending = undefined;
519
+ editRevision++;
520
+ draft = undefined;
521
+ invalidDraft = false;
522
+ }
523
+ const value = state.values[options.name];
524
+ if (value === lastValue &&
525
+ (composing ||
526
+ (draft !== undefined &&
527
+ (!invalidDraft || state.errors[options.name] !== undefined))))
528
+ return;
529
+ lastValue = value;
530
+ draft = undefined;
531
+ invalidDraft = false;
532
+ element.setCustomValidity("");
533
+ const revision = editRevision;
534
+ const encoded = yield* Schema.encodeUnknownEffect(codec)(value);
535
+ if (revision === editRevision &&
536
+ (yield* options.state).values[options.name] === value &&
537
+ element.value !== encoded) {
538
+ element.value = encoded;
539
+ }
540
+ })));
541
+ }),
542
+ onbeforeinput: EventHandler.make(Effect.fn(function* (event) {
543
+ if (event.isComposing || composing || !event.cancelable || format === undefined)
544
+ return;
545
+ if (event.inputType !== "deleteContentBackward" &&
546
+ event.inputType !== "deleteContentForward")
547
+ return;
548
+ const element = Dom.currentTarget(event);
549
+ const start = element.selectionStart;
550
+ if (start === null || start !== element.selectionEnd)
551
+ return;
552
+ const backwards = event.inputType === "deleteContentBackward";
553
+ const separators = parts
554
+ .filter((part) => typeof part === "string")
555
+ .join("");
556
+ let index = backwards ? start - 1 : start;
557
+ if (!separators.includes(element.value[index] ?? "\u0000"))
558
+ return;
559
+ while (index >= 0 &&
560
+ index < element.value.length &&
561
+ separators.includes(element.value[index])) {
562
+ index += backwards ? -1 : 1;
563
+ }
564
+ if (index < 0 || index >= element.value.length)
565
+ return;
566
+ event.preventDefault();
567
+ element.setRangeText("", index, index + 1, "start");
568
+ return yield* commit(element);
569
+ })),
570
+ oninput: EventHandler.make(Effect.fn(function* (event) {
571
+ input = Dom.currentTarget(event);
572
+ if (composing || event.isComposing)
573
+ return;
574
+ return yield* commit(input);
575
+ })),
576
+ oncompositionstart: EventHandler.make(() => Effect.sync(() => {
577
+ composing = true;
578
+ })),
579
+ oncompositionend: EventHandler.make(Effect.fn(function* (event) {
580
+ composing = false;
581
+ return yield* commit(Dom.currentTarget(event));
582
+ })),
583
+ };
584
+ };
585
+ }
586
+ // Only auto-format when fixed-width slots explicitly distinguish editable
587
+ // characters from literals. Ambiguous/variable-width codecs still retain drafts
588
+ // and validate, without guessing which characters the user meant to enter.
589
+ function maskDraftFormatter(parts) {
590
+ const slots = parts.filter((part) => typeof part !== "string");
591
+ const literals = parts.filter((part) => typeof part === "string").join("");
592
+ if (slots.length === 0 ||
593
+ slots.some((slot) => slot.length === undefined || slot.charset === undefined || slot.length < 1) ||
594
+ [...literals].some((character) => slots.some((slot) => matchesCharset(slot, character))))
595
+ return undefined;
596
+ return (text, cursor) => {
597
+ const characters = [];
598
+ let beforeCursor = 0;
599
+ for (let index = 0; index < text.length; index++) {
600
+ const character = text[index];
601
+ if (literals.includes(character))
602
+ continue;
603
+ characters.push(character);
604
+ if (index < cursor)
605
+ beforeCursor++;
606
+ }
607
+ let offset = 0;
608
+ let value = "";
609
+ let nextCursor = 0;
610
+ for (const [index, part] of parts.entries()) {
611
+ if (typeof part === "string") {
612
+ const complete = characters.length === slots.reduce((sum, slot) => sum + slot.length, 0);
613
+ const suffix = parts.slice(index + 1).every((remaining) => typeof remaining === "string");
614
+ if (offset < characters.length || (complete && suffix))
615
+ value += part;
616
+ continue;
617
+ }
618
+ const segment = characters.slice(offset, offset + part.length);
619
+ if (segment.some((character) => !matchesCharset(part, character)))
620
+ return undefined;
621
+ for (const character of segment) {
622
+ value += character;
623
+ offset++;
624
+ if (offset <= beforeCursor)
625
+ nextCursor = value.length;
626
+ }
627
+ }
628
+ if (offset !== characters.length)
629
+ return undefined;
630
+ return { value, cursor: beforeCursor === characters.length ? value.length : nextCursor };
631
+ };
632
+ }
633
+ function decodeMask(parts, display, options) {
634
+ return Effect.gen(function* () {
635
+ const value = {};
636
+ let offset = 0;
637
+ for (let index = 0; index < parts.length; index++) {
638
+ const part = parts[index];
639
+ if (typeof part === "string") {
640
+ if (!display.startsWith(part, offset))
641
+ return yield* invalidMask(display, options);
642
+ offset += part.length;
643
+ continue;
644
+ }
645
+ const nextLiteral = parts.slice(index + 1).find((next) => typeof next === "string");
646
+ const end = part.length === undefined
647
+ ? nextLiteral === undefined
648
+ ? display.length
649
+ : display.indexOf(nextLiteral, offset)
650
+ : offset + part.length;
651
+ if (end < offset)
652
+ return yield* invalidMask(display, options);
653
+ const encoded = display.slice(offset, end);
654
+ if (encoded.length === 0 ||
655
+ (part.length !== undefined && encoded.length !== part.length) ||
656
+ [...encoded].some((character) => !matchesCharset(part, character)))
657
+ return yield* invalidMask(display, options);
658
+ const decoded = yield* Schema.decodeEffect(part.codec)(encoded).pipe(Effect.mapError(() => new SchemaIssue.InvalidValue({ message: `Invalid ${part.name}` }, display, options)));
659
+ Reflect.set(value, part.name, decoded);
660
+ offset = end;
661
+ }
662
+ if (offset !== display.length || !isMaskValue(parts, value))
663
+ return yield* invalidMask(display, options);
664
+ return value;
665
+ });
666
+ }
667
+ function encodeMask(parts, value, options) {
668
+ return Effect.gen(function* () {
669
+ let display = "";
670
+ for (const part of parts) {
671
+ if (typeof part === "string") {
672
+ display += part;
673
+ continue;
674
+ }
675
+ const encoded = yield* Schema.encodeUnknownEffect(part.codec)(Reflect.get(value, part.name)).pipe(Effect.mapError(() => new SchemaIssue.InvalidValue({ message: `Invalid ${part.name}` }, value, options)));
676
+ if (encoded.length === 0 ||
677
+ (part.length !== undefined && encoded.length !== part.length) ||
678
+ [...encoded].some((character) => !matchesCharset(part, character)))
679
+ return yield* invalidMask(value, options);
680
+ display += encoded;
681
+ }
682
+ return display;
683
+ });
684
+ }
685
+ function isMaskValue(parts, value) {
686
+ return (typeof value === "object" &&
687
+ value !== null &&
688
+ parts.every((part) => typeof part === "string" || Reflect.has(value, part.name)));
689
+ }
690
+ function matchesCharset(slot, character) {
691
+ if (slot.charset === undefined)
692
+ return true;
693
+ if (slot.charset instanceof RegExp) {
694
+ slot.charset.lastIndex = 0;
695
+ return slot.charset.test(character);
696
+ }
697
+ return slot.charset(character);
698
+ }
699
+ function invalidMask(input, options) {
700
+ return Effect.fail(new SchemaIssue.InvalidValue({ message: "Invalid mask" }, input, options));
701
+ }
702
+ /**
703
+ * Binds a native text input to a structured mask value.
704
+ * @remarks
705
+ * A codec created by mask supplies the editable format. Fixed-width slots with
706
+ * explicit charsets receive literal insertion and caret-aware deletion; other
707
+ * codecs retain strict text entry. Incomplete drafts remain visible while decoded
708
+ * state keeps its previous value. Native custom validity and field error text
709
+ * prevent native submission of an incomplete mask. Composition is committed only
710
+ * after compositionend. New edits and resets supersede pending slot decoders.
711
+ * Each mounted input owns its draft observer and reset registration in Scope.
712
+ * @since 1.0.0
713
+ * @category Input codecs
714
+ */
715
+ export function MaskedInput(options, host) {
716
+ const { mask, ...inputOptions } = options;
717
+ return renderInput(inputOptions, host, "text", mask);
718
+ }
719
+ function checkboxProps(options) {
720
+ const checked = RefSubject.map(options.state, (state) => state.values[options.name] === true);
721
+ return () => ({
722
+ type: "checkbox",
723
+ name: options.name,
724
+ "aria-describedby": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined
725
+ ? undefined
726
+ : fieldErrorId(options.state, options.name)),
727
+ "aria-invalid": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined ? undefined : true),
728
+ "?checked": checked,
729
+ ".checked": checked,
730
+ onchange: EventHandler.make(Effect.fn((event) => decodeUpdatedField(options.state, options.name, Dom.currentTarget(event).checked))),
731
+ });
732
+ }
733
+ /**
734
+ * Binds a native checkbox to a boolean form field.
735
+ * @remarks
736
+ * Both the checked attribute and live property follow state, while the browser's
737
+ * real change event is decoded through the field codec.
738
+ * The rendered Scope owns the input, listener, and subscriptions. A custom host
739
+ * must apply merged name, ARIA, checked, and change props.
740
+ * @since 1.0.0
741
+ * @category Native controls
742
+ */
743
+ export function Checkbox(options, host) {
744
+ return Dom.renderHost()(options, host, checkboxProps(options), "", (props) => html `<input ...${props} />`);
745
+ }
746
+ function selectProps(options) {
747
+ return () => ({
748
+ name: options.name,
749
+ "aria-describedby": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined
750
+ ? undefined
751
+ : fieldErrorId(options.state, options.name)),
752
+ "aria-invalid": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined ? undefined : true),
753
+ ".value": RefSubject.map(options.state, (state) => String(state.values[options.name])),
754
+ onchange: EventHandler.make(Effect.fn((event) => decodeUpdatedField(options.state, options.name, Dom.currentTarget(event).value))),
755
+ });
756
+ }
757
+ /**
758
+ * Binds a native select element to a string form field.
759
+ * @remarks
760
+ * Native keyboard, accessibility, option, and form semantics remain browser-owned.
761
+ * The rendered Scope owns the select/content subscriptions. A custom host must
762
+ * preserve supplied name, ARIA, value, and change props.
763
+ * @since 1.0.0
764
+ * @category Native controls
765
+ */
766
+ export function Select(options, host) {
767
+ return Dom.renderHost()(options, host, selectProps(options), options.content, (props, content) => html `<select ...${props}>
768
+ ${content}
769
+ </select>`);
770
+ }
771
+ function labelProps(options) {
772
+ return () => ({ for: options.for });
773
+ }
774
+ /**
775
+ * Renders a native label with an explicit control relationship.
776
+ * @remarks
777
+ * The browser supplies click-to-focus and accessible-name behavior with no synthetic layer.
778
+ * The Scope owns label output/content; the referenced element remains separately owned.
779
+ * @since 1.0.0
780
+ * @category Field relationships
781
+ */
782
+ export function Label(options, host) {
783
+ return Dom.renderHost()(options, host, labelProps(options), options.content, (props, content) => html `<label ...${props}>${content}</label>`);
784
+ }
785
+ function descriptionProps() {
786
+ return () => ({});
787
+ }
788
+ /**
789
+ * Renders visible explanatory content in a neutral div.
790
+ *
791
+ * @remarks
792
+ * No control relationship is created automatically. The current input binding owns its generated
793
+ * error aria-describedby; do not assume consumer description IDs are merged into it.
794
+ * @since 1.0.0
795
+ * @category Field relationships
796
+ */
797
+ export function Description(options, host) {
798
+ return Dom.renderHost()(options, host, descriptionProps(), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
799
+ }
800
+ function errorProps(options) {
801
+ return () => ({
802
+ id: fieldErrorId(options.state, options.name),
803
+ role: "alert",
804
+ });
805
+ }
806
+ /**
807
+ * Renders the named field message with a generated ID and alert role.
808
+ *
809
+ * @remarks
810
+ * Bound inputs refer to this ID through aria-describedby and expose aria-invalid when an error
811
+ * exists. Keep the form ID stable and render one matching error host per field. Alert timing
812
+ * still depends on the browser and assistive technology; this is not an announcement queue.
813
+ * @since 1.0.0
814
+ * @category Field relationships
815
+ */
816
+ export function Error(options, host) {
817
+ const content = RefSubject.map(options.state, (state) => state.errors[options.name] ?? "");
818
+ return Dom.renderHost()(options, host, errorProps(options), content, (props, value) => html `<div ...${props}>${value}</div>`);
819
+ }
820
+ function submitProps() {
821
+ return () => ({ type: "submit" });
822
+ }
823
+ /**
824
+ * Renders a native `type=submit` button.
825
+ * @remarks
826
+ * Keyboard activation, form association, and accessibility stay browser-standard.
827
+ * The Scope owns the button/content; the surrounding Form owns submission sequencing.
828
+ * @since 1.0.0
829
+ * @category Form actions
830
+ */
831
+ export function Submit(options, host) {
832
+ return Dom.renderHost()(options, host, submitProps(), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
833
+ }
834
+ function resetProps(options) {
835
+ return () => ({
836
+ type: "reset",
837
+ onclick: EventHandler.preventDefault(EventHandler.fromEffectOrEventHandler(reset(options.state))),
838
+ });
839
+ }
840
+ /**
841
+ * Renders a native reset button that restores Typed form defaults.
842
+ * @remarks
843
+ * It prevents the browser's independent control mutation and resets the single
844
+ * RefSubject source of truth, clearing errors, metadata, and submitting state.
845
+ * The Scope owns the click handler/content. The supplied form state remains independently owned.
846
+ * @since 1.0.0
847
+ * @category Form actions
848
+ */
849
+ export function Reset(options, host) {
850
+ return Dom.renderHost()(options, host, resetProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
851
+ }
852
+ function groupProps(options) {
853
+ return () => ({ role: "group", "aria-label": options.label });
854
+ }
855
+ /**
856
+ * Renders an ARIA group with an optional accessible name.
857
+ * @remarks
858
+ * Related controls can expose their relationship without a framework-specific wrapper.
859
+ * The Scope owns host/content; child controls retain their own DOM/state contracts.
860
+ * @since 1.0.0
861
+ * @category Field relationships
862
+ */
863
+ export function Group(options, host) {
864
+ return Dom.renderHost()(options, host, groupProps(options), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
865
+ }
866
+ function pushProps(options) {
867
+ return () => ({
868
+ type: "button",
869
+ onclick: pushValue(options.state, options.name, options.value),
870
+ });
871
+ }
872
+ /**
873
+ * Renders a button that appends one item to an array field.
874
+ * @remarks
875
+ * Array mutation is immutable, typed, and marks the field dirty/touched.
876
+ * The Scope owns the button handler/content; state may outlive the button.
877
+ * @since 1.0.0
878
+ * @category Form actions
879
+ */
880
+ export function Push(options, host) {
881
+ return Dom.renderHost()(options, host, pushProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
882
+ }
883
+ function removeProps(options) {
884
+ return () => ({
885
+ type: "button",
886
+ onclick: removeValue(options.state, options.name, options.index),
887
+ });
888
+ }
889
+ /**
890
+ * Renders a button that removes one array item by index.
891
+ * @remarks
892
+ * Array mutation is immutable, typed, and marks the field dirty/touched.
893
+ * The Scope owns the button handler/content; state may outlive the button.
894
+ * @since 1.0.0
895
+ * @category Form actions
896
+ */
897
+ export function Remove(options, host) {
898
+ return Dom.renderHost()(options, host, removeProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
899
+ }
900
+ /**
901
+ * Assigns one decoded field value and updates dirty/touched metadata.
902
+ *
903
+ * @remarks
904
+ * The helper does not decode or validate the supplied value and does not clear an existing field
905
+ * error. Use validate for an explicit whole-form check. Dirty tracking compares against the
906
+ * default field with !==; updates replace the top-level values record.
907
+ * @since 1.0.0
908
+ * @category State transitions
909
+ */
910
+ export function setValue(state, key, value) {
911
+ return updateDecodedValue(state, key, value);
912
+ }
913
+ function updateDecodedValue(state, key, value) {
914
+ return RefSubject.update(state, (current) => ({
915
+ ...current,
916
+ values: updateRecord(current.values, key, value),
917
+ meta: {
918
+ ...current.meta,
919
+ [key]: { dirty: current.defaultValues[key] !== value, touched: true },
920
+ },
921
+ }));
922
+ }
923
+ function updateRecord(values, key, value) {
924
+ const updated = { ...values };
925
+ Reflect.set(updated, key, value);
926
+ return updated;
927
+ }
928
+ /**
929
+ * Restores default values and clears errors, metadata, and submitting.
930
+ *
931
+ * @remarks
932
+ * The defaultValues object is reused rather than deep-cloned. This state operation does not
933
+ * cancel a running submission Effect or reset independently owned result state.
934
+ * @since 1.0.0
935
+ * @category State transitions
936
+ */
937
+ export function reset(state) {
938
+ return RefSubject.update(state, (current) => ({
939
+ ...current,
940
+ values: current.defaultValues,
941
+ errors: {},
942
+ meta: {},
943
+ submitting: false,
944
+ })).pipe(Effect.tap(() => Effect.suspend(() => maskedInputResets.get(state)?.signal.onSuccess(undefined) ?? Effect.void)));
945
+ }
946
+ /**
947
+ * Appends one item to an array field and marks it dirty and touched.
948
+ * @remarks
949
+ * The immutable state transition is usable in tests, commands, or any renderer.
950
+ * The returned Effect performs one RefSubject update when run.
951
+ * @since 1.0.0
952
+ * @category State transitions
953
+ */
954
+ export function pushValue(state, name, value) {
955
+ return RefSubject.update(state, (current) => {
956
+ const previous = Reflect.get(current.values, name);
957
+ const values = Array.isArray(previous) ? [...previous, value] : [value];
958
+ return {
959
+ ...current,
960
+ values: updateRecord(current.values, name, values),
961
+ meta: { ...current.meta, [name]: { dirty: true, touched: true } },
962
+ };
963
+ });
964
+ }
965
+ /**
966
+ * Removes one item by index from an array field and marks it dirty and touched.
967
+ * @remarks
968
+ * The transition is explicit and renderer-independent; an out-of-range index leaves values unchanged.
969
+ * The returned Effect performs one RefSubject update when run.
970
+ * @since 1.0.0
971
+ * @category State transitions
972
+ */
973
+ export function removeValue(state, name, index) {
974
+ return RefSubject.update(state, (current) => {
975
+ const previous = Reflect.get(current.values, name);
976
+ const values = Array.isArray(previous)
977
+ ? previous.filter((_, currentIndex) => currentIndex !== index)
978
+ : [];
979
+ return {
980
+ ...current,
981
+ values: updateRecord(current.values, name, values),
982
+ meta: { ...current.meta, [name]: { dirty: true, touched: true } },
983
+ };
984
+ });
985
+ }
986
+ function formProps(options) {
987
+ const setSubmitting = Effect.fn((submitting) => RefSubject.update(options.state, (current) => ({ ...current, submitting })));
988
+ return () => ({
989
+ ref: options.state,
990
+ onsubmit: EventHandler.make(Effect.fn((event) => Effect.andThen(setSubmitting(true), Effect.matchEffect(validate(options.state), {
991
+ onFailure: Effect.fn(() => Effect.void),
992
+ onSuccess: Effect.fn((values) => {
993
+ const result = options.onValidSubmit?.(values, event);
994
+ return Effect.isEffect(result) ? result : Effect.void;
995
+ }),
996
+ })).pipe(Effect.ensuring(setSubmitting(false).pipe(Effect.catch(Effect.fn(() => Effect.void)))))), { preventDefault: true }),
997
+ onreset: EventHandler.preventDefault(EventHandler.fromEffectOrEventHandler(reset(options.state))),
998
+ });
999
+ }
1000
+ /**
1001
+ * Renders a native form and provides its state to bound descendants.
1002
+ *
1003
+ * @remarks
1004
+ * The submit listener synchronously prevents browser navigation, marks submitting, validates
1005
+ * decoded state, and runs onValidSubmit on success. Finalization clears submitting. Native
1006
+ * constraint validation can prevent submit dispatch before this handler runs. The root does not
1007
+ * read FormData, serialize a request, cancel duplicate submissions, or infer server errors.
1008
+ *
1009
+ * @example
1010
+ * ```ts
1011
+ * import { Form, Submit, TextInput, makeState } from "@typed/ui/Form"
1012
+ * import { Effect, Schema } from "effect"
1013
+ * import { html } from "@typed/template"
1014
+ *
1015
+ * const codec = Schema.Struct({ email: Schema.String })
1016
+ * const view = Effect.gen(function* () {
1017
+ * const state = yield* makeState(codec, { id: "signup", values: { email: "" } })
1018
+ * return Form({
1019
+ * state,
1020
+ * content: html`${TextInput({ state, name: "email" })}${Submit({ content: "Join" })}`,
1021
+ * onValidSubmit: (values) => Effect.log(`Submitting ${values.email}`)
1022
+ * })
1023
+ * })
1024
+ * ```
1025
+ * @since 1.0.0
1026
+ * @category Form roots
1027
+ */
1028
+ export function Form(options, host) {
1029
+ const rendered = Dom.renderHost()(options, host, formProps(options), options.content, (props, content) => html `<form ...${props}>${content}</form>`);
1030
+ return FxApi.provideService(rendered, CurrentForm, {
1031
+ state: options.state,
1032
+ });
1033
+ }
1034
+ /**
1035
+ * Binds a native form component family to one Struct codec.
1036
+ *
1037
+ * @remarks
1038
+ * state accepts decoded defaults and allocates a Scope-owned hydrated subject. Root provides
1039
+ * that form through CurrentForm. Bound controls select compatible field names from both decoded
1040
+ * and encoded schema types; their context requirement remains in the Fx until Root provides it.
1041
+ * Use explicit stable IDs when server rendering and hydrating.
1042
+ *
1043
+ * @example
1044
+ * ```ts
1045
+ * import { Schema } from "effect";
1046
+ * import { html } from "@typed/template";
1047
+ * import { RefSubject } from "@typed/fx";
1048
+ * import { component } from "@typed/template";
1049
+ * import * as Form from "@typed/ui/Form";
1050
+ *
1051
+ * const Order = Form.make(Schema.Struct({
1052
+ * copies: Schema.FiniteFromString.pipe(Schema.check(Schema.isGreaterThan(0))),
1053
+ * includeNotes: Schema.Boolean,
1054
+ * }));
1055
+ *
1056
+ * export const PrintOrder = component(function* () {
1057
+ * const form = yield* Order.state({ copies: 1, includeNotes: false }, { id: "print-order" });
1058
+ * const submitting = RefSubject.map(form, (state) => state.submitting);
1059
+ * const preview = yield* RefSubject.make("No print request preview yet.");
1060
+ * return html`<section>${Order.Root({
1061
+ * form,
1062
+ * content: [
1063
+ * Order.Label({ for: "order-copies", content: "Copies" }),
1064
+ * Order.NumberInput({ name: "copies", props: { id: "order-copies", min: 1, required: true } }),
1065
+ * Order.Error({ name: "copies" }),
1066
+ * Order.Checkbox({ name: "includeNotes", props: { id: "order-notes" } }),
1067
+ * Order.Label({ for: "order-notes", content: "Include speaker notes" }),
1068
+ * Order.Submit({ content: "Preview print request", props: { "?disabled": submitting } }),
1069
+ * Order.Reset({ content: "Restore defaults" }),
1070
+ * ],
1071
+ * onValidSubmit: (values) => RefSubject.set(
1072
+ * preview,
1073
+ * `${values.copies} copies; notes ${values.includeNotes ? "included" : "excluded"}.`,
1074
+ * ),
1075
+ * })}<p role="status">${preview}</p></section>`;
1076
+ * });
1077
+ * ```
1078
+ * @since 1.0.0
1079
+ * @category Schema-bound components
1080
+ */
1081
+ export function make(codec) {
1082
+ const state = (values, options = {}) => makeState(codec, { ...options, values });
1083
+ const Root = (options, host) => {
1084
+ const { form, ...rootOptions } = options;
1085
+ return Form({ ...rootOptions, state: form }, host);
1086
+ };
1087
+ const BoundCheckbox = (options, host) => withCurrentForm()((state) => {
1088
+ const inputOptions = { ...options, state };
1089
+ return Checkbox(inputOptions, host);
1090
+ });
1091
+ const BoundSelect = (options, host) => withCurrentForm()((state) => {
1092
+ const inputOptions = { ...options, state };
1093
+ return Select(inputOptions, host);
1094
+ });
1095
+ const BoundError = (options, host) => withCurrentForm()((state) => {
1096
+ const inputOptions = { ...options, state };
1097
+ return Error(inputOptions, host);
1098
+ });
1099
+ const BoundReset = (options, host) => withCurrentForm()((state) => {
1100
+ const inputOptions = { ...options, state };
1101
+ return Reset(inputOptions, host);
1102
+ });
1103
+ const BoundPush = (options, host) => withCurrentForm()((state) => {
1104
+ const inputOptions = { ...options, state };
1105
+ return Push(inputOptions, host);
1106
+ });
1107
+ const BoundRemove = (options, host) => withCurrentForm()((state) => {
1108
+ const inputOptions = { ...options, state };
1109
+ return Remove(inputOptions, host);
1110
+ });
1111
+ return {
1112
+ codec,
1113
+ state,
1114
+ Root,
1115
+ TextInput: makeSchemaBoundInput(codec, "text"),
1116
+ SearchInput: makeSchemaBoundInput(codec, "search"),
1117
+ EmailInput: makeSchemaBoundInput(codec, "email"),
1118
+ UrlInput: makeSchemaBoundInput(codec, "url"),
1119
+ TelInput: makeSchemaBoundInput(codec, "tel"),
1120
+ PasswordInput: makeSchemaBoundInput(codec, "password"),
1121
+ HiddenInput: makeSchemaBoundInput(codec, "hidden"),
1122
+ ColorInput: makeSchemaBoundInput(codec, "color"),
1123
+ TimeInput: makeSchemaBoundInput(codec, "time"),
1124
+ DateTimeLocalInput: makeSchemaBoundInput(codec, "datetime-local"),
1125
+ MonthInput: makeSchemaBoundInput(codec, "month"),
1126
+ WeekInput: makeSchemaBoundInput(codec, "week"),
1127
+ NumberInput: makeSchemaBoundInput(codec, "number"),
1128
+ RangeInput: makeSchemaBoundInput(codec, "range"),
1129
+ DateInput: makeSchemaBoundInput(codec, "date"),
1130
+ MaskedInput: makeSchemaBoundInput(codec, "text"),
1131
+ Checkbox: BoundCheckbox,
1132
+ Select: BoundSelect,
1133
+ Error: BoundError,
1134
+ Reset: BoundReset,
1135
+ Push: BoundPush,
1136
+ Remove: BoundRemove,
1137
+ Label,
1138
+ Description,
1139
+ Submit,
1140
+ Group,
1141
+ };
1142
+ }