@typed/ui 1.0.0-beta.5 → 1.0.0-beta.7
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/dist/Alert.d.ts +35 -41
- package/dist/Alert.d.ts.map +1 -1
- package/dist/Alert.js +23 -20
- package/dist/Button.d.ts +34 -78
- package/dist/Button.d.ts.map +1 -1
- package/dist/Button.js +16 -12
- package/dist/Carousel.d.ts +35 -236
- package/dist/Carousel.d.ts.map +1 -1
- package/dist/Carousel.js +13 -136
- package/dist/Checkbox.d.ts +55 -90
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox.js +44 -38
- package/dist/Collection.d.ts +15 -15
- package/dist/Collection.js +7 -7
- package/dist/Combobox.d.ts +38 -187
- package/dist/Combobox.d.ts.map +1 -1
- package/dist/Combobox.js +9 -80
- package/dist/Component.d.ts +30 -24
- package/dist/Component.d.ts.map +1 -1
- package/dist/Component.js +23 -12
- package/dist/Composite.d.ts +50 -50
- package/dist/Composite.js +20 -20
- package/dist/Dialog.d.ts +34 -34
- package/dist/Dialog.d.ts.map +1 -1
- package/dist/Dialog.js +16 -14
- package/dist/Disclosure.d.ts +15 -15
- package/dist/Disclosure.js +6 -6
- package/dist/Dom/Events.d.ts +4 -4
- package/dist/Dom/Events.js +4 -4
- package/dist/Dom/Props.d.ts +9 -9
- package/dist/Dom/Props.js +4 -4
- package/dist/Dom/Refs.d.ts +2 -2
- package/dist/Dom/Refs.js +1 -1
- package/dist/Dom/Render.d.ts +2 -2
- package/dist/Dom/Render.js +2 -2
- package/dist/Dom/Types.d.ts +33 -33
- package/dist/Dom/index.d.ts +5 -5
- package/dist/Dom/index.d.ts.map +1 -1
- package/dist/Dom/index.js +4 -4
- package/dist/Dom.d.ts +1 -1
- package/dist/Dom.js +1 -1
- package/dist/Focusable.d.ts +5 -5
- package/dist/Focusable.js +1 -1
- package/dist/Form.d.ts +565 -533
- package/dist/Form.d.ts.map +1 -1
- package/dist/Form.js +350 -195
- package/dist/Grid.d.ts +39 -220
- package/dist/Grid.d.ts.map +1 -1
- package/dist/Grid.js +12 -107
- package/dist/Group.d.ts +50 -69
- package/dist/Group.d.ts.map +1 -1
- package/dist/Group.js +33 -25
- package/dist/Heading.d.ts +41 -40
- package/dist/Heading.d.ts.map +1 -1
- package/dist/Heading.js +26 -16
- package/dist/Hovercard.d.ts +18 -18
- package/dist/Hovercard.js +6 -6
- package/dist/HttpRouter.d.ts +3 -3
- package/dist/HttpRouter.js +5 -5
- package/dist/Link.d.ts +17 -21
- package/dist/Link.d.ts.map +1 -1
- package/dist/Link.js +11 -0
- package/dist/Listbox.d.ts +31 -163
- package/dist/Listbox.d.ts.map +1 -1
- package/dist/Listbox.js +9 -80
- package/dist/Menu.d.ts +67 -376
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Menu.js +17 -179
- package/dist/Menubar.d.ts +24 -142
- package/dist/Menubar.d.ts.map +1 -1
- package/dist/Menubar.js +8 -66
- package/dist/Meter.d.ts +72 -132
- package/dist/Meter.d.ts.map +1 -1
- package/dist/Meter.js +35 -42
- package/dist/NativeDetails.d.ts +1 -1
- package/dist/NativeDetails.js +1 -1
- package/dist/NativeDialog.d.ts +7 -5
- package/dist/NativeDialog.d.ts.map +1 -1
- package/dist/NativeDialog.js +42 -4
- package/dist/NativePopover.d.ts +6 -4
- package/dist/NativePopover.d.ts.map +1 -1
- package/dist/NativePopover.js +43 -5
- package/dist/Popover.d.ts +18 -19
- package/dist/Popover.d.ts.map +1 -1
- package/dist/Popover.js +7 -8
- package/dist/RadioGroup.d.ts +86 -188
- package/dist/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup.js +55 -105
- package/dist/Role.d.ts +4 -4
- package/dist/Role.js +1 -1
- package/dist/Select.d.ts +109 -220
- package/dist/Select.d.ts.map +1 -1
- package/dist/Select.js +67 -117
- package/dist/Separator.d.ts +30 -26
- package/dist/Separator.d.ts.map +1 -1
- package/dist/Separator.js +16 -10
- package/dist/Slider.d.ts +64 -111
- package/dist/Slider.d.ts.map +1 -1
- package/dist/Slider.js +47 -42
- package/dist/SpinButton.d.ts +64 -111
- package/dist/SpinButton.d.ts.map +1 -1
- package/dist/SpinButton.js +47 -42
- package/dist/Storybook.d.ts +2 -2
- package/dist/Storybook.js +1 -1
- package/dist/Switch.d.ts +52 -91
- package/dist/Switch.d.ts.map +1 -1
- package/dist/Switch.js +30 -36
- package/dist/Tabs.d.ts +44 -224
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/Tabs.js +10 -94
- package/dist/Toolbar.d.ts +24 -142
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +8 -66
- package/dist/Tooltip.d.ts +20 -20
- package/dist/Tooltip.js +6 -6
- package/dist/Tree.d.ts +43 -229
- package/dist/Tree.d.ts.map +1 -1
- package/dist/Tree.js +12 -113
- package/dist/TreeGrid.d.ts +45 -264
- package/dist/TreeGrid.d.ts.map +1 -1
- package/dist/TreeGrid.js +13 -125
- package/dist/VisuallyHidden.d.ts +41 -27
- package/dist/VisuallyHidden.d.ts.map +1 -1
- package/dist/VisuallyHidden.js +27 -10
- package/dist/WindowSplitter.d.ts +68 -108
- package/dist/WindowSplitter.d.ts.map +1 -1
- package/dist/WindowSplitter.js +106 -23
- package/package.json +9 -8
package/dist/Form.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
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
|
+
*/
|
|
1
13
|
import * as Effect from "effect/Effect";
|
|
2
14
|
import * as Context from "effect/Context";
|
|
3
15
|
import * as Schema from "effect/Schema";
|
|
4
16
|
import * as SchemaIssue from "effect/SchemaIssue";
|
|
5
17
|
import * as SchemaTransformation from "effect/SchemaTransformation";
|
|
6
|
-
import { Fx as FxApi, RefSubject } from "@typed/fx";
|
|
18
|
+
import { Fx as FxApi, RefSubject, Subject } from "@typed/fx";
|
|
19
|
+
import * as Scope from "effect/Scope";
|
|
7
20
|
import { EventHandler, html, } from "@typed/template";
|
|
8
21
|
import * as Dom from "./Dom.js";
|
|
9
22
|
let nextFormId = 0;
|
|
@@ -11,16 +24,13 @@ let nextFormId = 0;
|
|
|
11
24
|
* Effect context service used by schema-bound form controls.
|
|
12
25
|
*
|
|
13
26
|
* @remarks
|
|
14
|
-
* ## Why
|
|
15
27
|
* Bound controls avoid threading `state` through every call while their
|
|
16
28
|
* service requirement remains visible in the Fx type.
|
|
17
29
|
*
|
|
18
|
-
* ## Ownership and lifetime
|
|
19
30
|
* `Form` provides the service for its child render lifetime; use outside that
|
|
20
31
|
* boundary fails with the ordinary Effect missing-service defect.
|
|
21
|
-
*
|
|
22
32
|
* @since 1.0.0
|
|
23
|
-
* @category
|
|
33
|
+
* @category Form context
|
|
24
34
|
*/
|
|
25
35
|
export const CurrentForm = Context.Service("@typed/ui/Form/CurrentForm");
|
|
26
36
|
function withCurrentForm() {
|
|
@@ -43,10 +53,8 @@ function emptyOptionalFields() {
|
|
|
43
53
|
* Builds the serializable schema for a form's hydrated state.
|
|
44
54
|
*
|
|
45
55
|
* @remarks
|
|
46
|
-
* ## Why
|
|
47
56
|
* The hydration payload needs validation independent from runtime-only field codecs.
|
|
48
57
|
*
|
|
49
|
-
* ## Ownership and lifetime
|
|
50
58
|
* Pure schema construction; the returned Schema acquires no Scope or subscription.
|
|
51
59
|
*
|
|
52
60
|
* @example
|
|
@@ -57,9 +65,8 @@ function emptyOptionalFields() {
|
|
|
57
65
|
* const codec = Schema.Struct({ email: Schema.String })
|
|
58
66
|
* const stateCodec = StateSchema(codec)
|
|
59
67
|
* ```
|
|
60
|
-
*
|
|
61
68
|
* @since 1.0.0
|
|
62
|
-
* @category schemas
|
|
69
|
+
* @category Hydration schemas
|
|
63
70
|
*/
|
|
64
71
|
export function StateSchema(codec) {
|
|
65
72
|
return Schema.Struct({
|
|
@@ -74,11 +81,9 @@ export function StateSchema(codec) {
|
|
|
74
81
|
* Creates a Scope-owned hydrated form RefSubject from a Struct codec.
|
|
75
82
|
*
|
|
76
83
|
* @remarks
|
|
77
|
-
* ## Why
|
|
78
84
|
* One constructor establishes values, defaults, validation state, field codecs,
|
|
79
85
|
* and hydration identity consistently.
|
|
80
86
|
*
|
|
81
|
-
* ## Ownership and lifetime
|
|
82
87
|
* Requires `Scope.Scope`. Provide an explicit `id` during SSR; the counter-based
|
|
83
88
|
* fallback is process/order dependent. Only state data hydrates—`codec` and
|
|
84
89
|
* `fields` are reattached from the live Struct on each runtime.
|
|
@@ -94,9 +99,8 @@ export function StateSchema(codec) {
|
|
|
94
99
|
* return state
|
|
95
100
|
* })
|
|
96
101
|
* ```
|
|
97
|
-
*
|
|
98
102
|
* @since 1.0.0
|
|
99
|
-
* @category
|
|
103
|
+
* @category State construction
|
|
100
104
|
*/
|
|
101
105
|
export function makeState(codec, initial) {
|
|
102
106
|
const id = initial.id ?? `typed-form-${++nextFormId}`;
|
|
@@ -128,6 +132,9 @@ function decodeUpdatedField(state, name, encoded) {
|
|
|
128
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)));
|
|
129
133
|
}
|
|
130
134
|
function inputProps(options, type, codec) {
|
|
135
|
+
const parts = maskParts.get(codec);
|
|
136
|
+
if (parts !== undefined)
|
|
137
|
+
return maskedInputProps(options, type, codec, parts);
|
|
131
138
|
return () => ({
|
|
132
139
|
type,
|
|
133
140
|
name: options.name,
|
|
@@ -141,7 +148,7 @@ function inputProps(options, type, codec) {
|
|
|
141
148
|
}
|
|
142
149
|
function renderInput(options, host, type, defaultCodec) {
|
|
143
150
|
const codec = options.codec ?? defaultCodec;
|
|
144
|
-
return Dom.renderHost()(options, host, inputProps(options, type, codec), "", (props) => html `<input ...${props} />`);
|
|
151
|
+
return FxApi.suspend(() => Dom.renderHost()(options, host, inputProps(options, type, codec), "", (props) => html `<input ...${props} />`));
|
|
145
152
|
}
|
|
146
153
|
function makeInput(type, codec) {
|
|
147
154
|
return function (options, host) {
|
|
@@ -161,11 +168,9 @@ function makeSchemaBoundInput(formCodec, type) {
|
|
|
161
168
|
* Binds a native `input[type=text]` to a string field.
|
|
162
169
|
*
|
|
163
170
|
* @remarks
|
|
164
|
-
* ## Why
|
|
165
171
|
* The control uses the browser's real input event and a Schema codec while
|
|
166
172
|
* keeping state independently testable.
|
|
167
173
|
*
|
|
168
|
-
* ## Ownership and lifetime
|
|
169
174
|
* The control Scope owns DOM listeners/subscriptions; the supplied `FormState`
|
|
170
175
|
* may outlive the rendered input. A custom host must apply all merged props.
|
|
171
176
|
*
|
|
@@ -180,173 +185,124 @@ function makeSchemaBoundInput(formCodec, type) {
|
|
|
180
185
|
* return TextInput({ state, name: "name" })
|
|
181
186
|
* })
|
|
182
187
|
* ```
|
|
183
|
-
*
|
|
184
188
|
* @since 1.0.0
|
|
185
|
-
* @category
|
|
189
|
+
* @category Native controls
|
|
186
190
|
*/
|
|
187
191
|
export const TextInput = makeInput("text", Schema.String);
|
|
188
192
|
/**
|
|
189
193
|
* Binds a native search input to a string field.
|
|
190
194
|
* @remarks
|
|
191
|
-
* ## Why
|
|
192
195
|
* Preserves the platform's search-input semantics while sharing Typed validation.
|
|
193
|
-
* ## Ownership and lifetime
|
|
194
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
195
196
|
* @since 1.0.0
|
|
196
|
-
* @category
|
|
197
|
+
* @category Native controls
|
|
197
198
|
*/
|
|
198
199
|
export const SearchInput = makeInput("search", Schema.String);
|
|
199
200
|
/**
|
|
200
201
|
* Binds a native email input to a string field.
|
|
201
202
|
* @remarks
|
|
202
|
-
* ## Why
|
|
203
203
|
* Keeps browser email affordances and constraints available alongside Schema validation.
|
|
204
|
-
* ## Ownership and lifetime
|
|
205
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
206
204
|
* @since 1.0.0
|
|
207
|
-
* @category
|
|
205
|
+
* @category Native controls
|
|
208
206
|
*/
|
|
209
207
|
export const EmailInput = makeInput("email", Schema.String);
|
|
210
208
|
/**
|
|
211
209
|
* Binds a native URL input to a string field.
|
|
212
210
|
* @remarks
|
|
213
|
-
* ## Why
|
|
214
211
|
* Keeps browser URL affordances while the schema remains the decoded state contract.
|
|
215
|
-
* ## Ownership and lifetime
|
|
216
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
217
212
|
* @since 1.0.0
|
|
218
|
-
* @category
|
|
213
|
+
* @category Native controls
|
|
219
214
|
*/
|
|
220
215
|
export const UrlInput = makeInput("url", Schema.String);
|
|
221
216
|
/**
|
|
222
217
|
* Binds a native telephone input to a string field.
|
|
223
218
|
* @remarks
|
|
224
|
-
* ## Why
|
|
225
219
|
* Preserves platform telephone keyboards and autocomplete behavior.
|
|
226
|
-
* ## Ownership and lifetime
|
|
227
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
228
220
|
* @since 1.0.0
|
|
229
|
-
* @category
|
|
221
|
+
* @category Native controls
|
|
230
222
|
*/
|
|
231
223
|
export const TelInput = makeInput("tel", Schema.String);
|
|
232
224
|
/**
|
|
233
225
|
* Binds a native password input to a string field.
|
|
234
226
|
* @remarks
|
|
235
|
-
* ## Why
|
|
236
227
|
* Uses browser password handling instead of recreating sensitive-input behavior.
|
|
237
|
-
* ## Ownership and lifetime
|
|
238
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
239
228
|
* @since 1.0.0
|
|
240
|
-
* @category
|
|
229
|
+
* @category Native controls
|
|
241
230
|
*/
|
|
242
231
|
export const PasswordInput = makeInput("password", Schema.String);
|
|
243
232
|
/**
|
|
244
233
|
* Binds a native hidden input to a string field.
|
|
245
234
|
* @remarks
|
|
246
|
-
* ## Why
|
|
247
235
|
* Allows standards-based form serialization for non-visible values.
|
|
248
|
-
* ## Ownership and lifetime
|
|
249
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
250
236
|
* @since 1.0.0
|
|
251
|
-
* @category
|
|
237
|
+
* @category Native controls
|
|
252
238
|
*/
|
|
253
239
|
export const HiddenInput = makeInput("hidden", Schema.String);
|
|
254
240
|
/**
|
|
255
241
|
* Binds a native color input to a string field.
|
|
256
242
|
* @remarks
|
|
257
|
-
* ## Why
|
|
258
243
|
* Retains the browser's color picker while state receives its string value.
|
|
259
|
-
* ## Ownership and lifetime
|
|
260
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
261
244
|
* @since 1.0.0
|
|
262
|
-
* @category
|
|
245
|
+
* @category Native controls
|
|
263
246
|
*/
|
|
264
247
|
export const ColorInput = makeInput("color", Schema.String);
|
|
265
248
|
/**
|
|
266
249
|
* Binds a native time input to a string field.
|
|
267
250
|
* @remarks
|
|
268
|
-
* ## Why
|
|
269
251
|
* Preserves browser locale and time-entry behavior without inventing a picker.
|
|
270
|
-
* ## Ownership and lifetime
|
|
271
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
272
252
|
* @since 1.0.0
|
|
273
|
-
* @category
|
|
253
|
+
* @category Native controls
|
|
274
254
|
*/
|
|
275
255
|
export const TimeInput = makeInput("time", Schema.String);
|
|
276
256
|
/**
|
|
277
257
|
* Binds a native local date-time input to a string field.
|
|
278
258
|
* @remarks
|
|
279
|
-
* ## Why
|
|
280
259
|
* Keeps the platform's local date-time UI and its standard encoded value.
|
|
281
|
-
* ## Ownership and lifetime
|
|
282
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
283
260
|
* @since 1.0.0
|
|
284
|
-
* @category
|
|
261
|
+
* @category Native controls
|
|
285
262
|
*/
|
|
286
263
|
export const DateTimeLocalInput = makeInput("datetime-local", Schema.String);
|
|
287
264
|
/**
|
|
288
265
|
* Binds a native month input to a string field.
|
|
289
266
|
* @remarks
|
|
290
|
-
* ## Why
|
|
291
267
|
* Preserves the browser month picker and standardized string encoding.
|
|
292
|
-
* ## Ownership and lifetime
|
|
293
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
294
268
|
* @since 1.0.0
|
|
295
|
-
* @category
|
|
269
|
+
* @category Native controls
|
|
296
270
|
*/
|
|
297
271
|
export const MonthInput = makeInput("month", Schema.String);
|
|
298
272
|
/**
|
|
299
273
|
* Binds a native week input to a string field.
|
|
300
274
|
* @remarks
|
|
301
|
-
* ## Why
|
|
302
275
|
* Preserves platform week-entry behavior and standardized string encoding.
|
|
303
|
-
* ## Ownership and lifetime
|
|
304
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
305
276
|
* @since 1.0.0
|
|
306
|
-
* @category
|
|
277
|
+
* @category Native controls
|
|
307
278
|
*/
|
|
308
279
|
export const WeekInput = makeInput("week", Schema.String);
|
|
309
280
|
/**
|
|
310
281
|
* Binds a native number input to a finite number field.
|
|
311
282
|
* @remarks
|
|
312
|
-
* ## Why
|
|
313
283
|
* `FiniteFromString` makes the browser's string value an explicit typed decode.
|
|
314
|
-
* ## Ownership and lifetime
|
|
315
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
316
284
|
* @since 1.0.0
|
|
317
|
-
* @category
|
|
285
|
+
* @category Native controls
|
|
318
286
|
*/
|
|
319
287
|
export const NumberInput = makeInput("number", Schema.FiniteFromString);
|
|
320
288
|
/**
|
|
321
289
|
* Binds a native range input to a finite number field.
|
|
322
290
|
* @remarks
|
|
323
|
-
* ## Why
|
|
324
291
|
* Retains native slider interaction while exposing a decoded numeric value.
|
|
325
|
-
* ## Ownership and lifetime
|
|
326
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
327
292
|
* @since 1.0.0
|
|
328
|
-
* @category
|
|
293
|
+
* @category Native controls
|
|
329
294
|
*/
|
|
330
295
|
export const RangeInput = makeInput("range", Schema.FiniteFromString);
|
|
331
296
|
/**
|
|
332
297
|
* Binds a native date input to a `Date` field.
|
|
333
298
|
* @remarks
|
|
334
|
-
* ## Why
|
|
335
299
|
* `DateFromString` makes the native encoded value's conversion explicit and fallible.
|
|
336
|
-
* ## Ownership and lifetime
|
|
337
|
-
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
338
300
|
* @since 1.0.0
|
|
339
|
-
* @category
|
|
301
|
+
* @category Native controls
|
|
340
302
|
*/
|
|
341
303
|
export const DateInput = makeInput("date", Schema.DateFromString);
|
|
342
304
|
/**
|
|
343
|
-
* Converts native
|
|
344
|
-
* @remarks
|
|
345
|
-
* ## Why
|
|
346
|
-
* `Object.fromEntries` silently loses repeated names, which breaks checkbox,
|
|
347
|
-
* multiselect, and multi-file submissions.
|
|
348
|
-
* ## Ownership and lifetime
|
|
349
|
-
* The function is synchronous and resource-free; File values are not cloned.
|
|
305
|
+
* Converts native form data to a record, preserving repeated names as arrays.
|
|
350
306
|
* @example
|
|
351
307
|
* ```ts
|
|
352
308
|
* import { formDataToRecord } from "@typed/ui/Form"
|
|
@@ -357,7 +313,7 @@ export const DateInput = makeInput("date", Schema.DateFromString);
|
|
|
357
313
|
* const record = formDataToRecord(data)
|
|
358
314
|
* ```
|
|
359
315
|
* @since 1.0.0
|
|
360
|
-
* @category
|
|
316
|
+
* @category Browser form data
|
|
361
317
|
*/
|
|
362
318
|
export function formDataToRecord(data) {
|
|
363
319
|
const result = {};
|
|
@@ -375,10 +331,8 @@ export function formDataToRecord(data) {
|
|
|
375
331
|
/**
|
|
376
332
|
* Decodes native FormData through an Effect Schema codec.
|
|
377
333
|
* @remarks
|
|
378
|
-
* ## Why
|
|
379
334
|
* Browser serialization, repeated values, Files, and typed validation meet at
|
|
380
335
|
* one explicit fallible boundary.
|
|
381
|
-
* ## Ownership and lifetime
|
|
382
336
|
* The returned Effect is lazy and owns no browser resource; it references File
|
|
383
337
|
* objects present in the supplied FormData.
|
|
384
338
|
* @example
|
|
@@ -389,21 +343,21 @@ export function formDataToRecord(data) {
|
|
|
389
343
|
* const decode = decodeFormData(Schema.Struct({ name: Schema.String }), new FormData())
|
|
390
344
|
* ```
|
|
391
345
|
* @since 1.0.0
|
|
392
|
-
* @category
|
|
346
|
+
* @category Browser form data
|
|
393
347
|
*/
|
|
394
348
|
export function decodeFormData(codec, data) {
|
|
395
349
|
return Schema.decodeEffect(codec)(formDataToRecord(data));
|
|
396
350
|
}
|
|
397
351
|
/**
|
|
398
|
-
*
|
|
352
|
+
* Checks retained decoded values against the form codec Type.
|
|
353
|
+
*
|
|
399
354
|
* @remarks
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* errors; on failure it records messages and re-fails with `SchemaError`.
|
|
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.
|
|
405
359
|
* @since 1.0.0
|
|
406
|
-
* @category
|
|
360
|
+
* @category Validation
|
|
407
361
|
*/
|
|
408
362
|
export function validate(state) {
|
|
409
363
|
return Effect.flatMap(state, (current) => Schema.decodeUnknownEffect(Schema.toType(state.codec))(current.values)).pipe(Effect.flatMap((values) => Effect.as(RefSubject.update(state, (current) => ({
|
|
@@ -424,9 +378,7 @@ function formErrors(values, errors, error) {
|
|
|
424
378
|
/**
|
|
425
379
|
* Creates a named, Schema-decoded mask slot.
|
|
426
380
|
* @remarks
|
|
427
|
-
* ## Why
|
|
428
381
|
* Length and character constraints are expressed beside the codec that owns conversion.
|
|
429
|
-
* ## Ownership and lifetime
|
|
430
382
|
* Pure constructor; the returned descriptor retains the codec but acquires no Scope.
|
|
431
383
|
* @example
|
|
432
384
|
* ```ts
|
|
@@ -436,19 +388,21 @@ function formErrors(values, errors, error) {
|
|
|
436
388
|
* const areaCode = slot("area", Schema.String, { length: 3, charset: /[0-9]/ })
|
|
437
389
|
* ```
|
|
438
390
|
* @since 1.0.0
|
|
439
|
-
* @category
|
|
391
|
+
* @category Input codecs
|
|
440
392
|
*/
|
|
441
393
|
export function slot(name, codec, options = {}) {
|
|
442
394
|
return { _tag: "MaskSlot", name, codec, ...options };
|
|
443
395
|
}
|
|
396
|
+
const maskParts = new WeakMap();
|
|
397
|
+
const maskedInputResets = new WeakMap();
|
|
444
398
|
/**
|
|
445
399
|
* Builds a bidirectional Schema codec from literal text and named slots.
|
|
446
400
|
* @remarks
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
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.
|
|
452
406
|
* @example
|
|
453
407
|
* ```ts
|
|
454
408
|
* import { mask, slot } from "@typed/ui/Form"
|
|
@@ -458,16 +412,223 @@ export function slot(name, codec, options = {}) {
|
|
|
458
412
|
* slot("number", Schema.String, { length: 7 }))
|
|
459
413
|
* ```
|
|
460
414
|
* @since 1.0.0
|
|
461
|
-
* @category
|
|
415
|
+
* @category Input codecs
|
|
462
416
|
*/
|
|
463
417
|
export function mask(...parts) {
|
|
464
418
|
const valueSchema = Schema.declare((value) => typeof value === "object" &&
|
|
465
419
|
value !== null &&
|
|
466
420
|
parts.every((part) => typeof part === "string" || Reflect.has(value, part.name)));
|
|
467
|
-
|
|
421
|
+
const codec = Schema.String.pipe(Schema.decodeTo(valueSchema, SchemaTransformation.transformOrFail({
|
|
468
422
|
decode: (display, options) => decodeMask(parts, display, options),
|
|
469
423
|
encode: (value, options) => encodeMask(parts, value, options),
|
|
470
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
|
+
};
|
|
471
632
|
}
|
|
472
633
|
function decodeMask(parts, display, options) {
|
|
473
634
|
return Effect.gen(function* () {
|
|
@@ -541,14 +702,15 @@ function invalidMask(input, options) {
|
|
|
541
702
|
/**
|
|
542
703
|
* Binds a native text input to a structured mask value.
|
|
543
704
|
* @remarks
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
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.
|
|
550
712
|
* @since 1.0.0
|
|
551
|
-
* @category
|
|
713
|
+
* @category Input codecs
|
|
552
714
|
*/
|
|
553
715
|
export function MaskedInput(options, host) {
|
|
554
716
|
const { mask, ...inputOptions } = options;
|
|
@@ -571,14 +733,12 @@ function checkboxProps(options) {
|
|
|
571
733
|
/**
|
|
572
734
|
* Binds a native checkbox to a boolean form field.
|
|
573
735
|
* @remarks
|
|
574
|
-
* ## Why
|
|
575
736
|
* Both the checked attribute and live property follow state, while the browser's
|
|
576
737
|
* real change event is decoded through the field codec.
|
|
577
|
-
* ## Ownership and lifetime
|
|
578
738
|
* The rendered Scope owns the input, listener, and subscriptions. A custom host
|
|
579
739
|
* must apply merged name, ARIA, checked, and change props.
|
|
580
740
|
* @since 1.0.0
|
|
581
|
-
* @category
|
|
741
|
+
* @category Native controls
|
|
582
742
|
*/
|
|
583
743
|
export function Checkbox(options, host) {
|
|
584
744
|
return Dom.renderHost()(options, host, checkboxProps(options), "", (props) => html `<input ...${props} />`);
|
|
@@ -597,13 +757,11 @@ function selectProps(options) {
|
|
|
597
757
|
/**
|
|
598
758
|
* Binds a native select element to a string form field.
|
|
599
759
|
* @remarks
|
|
600
|
-
* ## Why
|
|
601
760
|
* Native keyboard, accessibility, option, and form semantics remain browser-owned.
|
|
602
|
-
* ## Ownership and lifetime
|
|
603
761
|
* The rendered Scope owns the select/content subscriptions. A custom host must
|
|
604
762
|
* preserve supplied name, ARIA, value, and change props.
|
|
605
763
|
* @since 1.0.0
|
|
606
|
-
* @category
|
|
764
|
+
* @category Native controls
|
|
607
765
|
*/
|
|
608
766
|
export function Select(options, host) {
|
|
609
767
|
return Dom.renderHost()(options, host, selectProps(options), options.content, (props, content) => html `<select ...${props}>
|
|
@@ -616,12 +774,10 @@ function labelProps(options) {
|
|
|
616
774
|
/**
|
|
617
775
|
* Renders a native label with an explicit control relationship.
|
|
618
776
|
* @remarks
|
|
619
|
-
* ## Why
|
|
620
777
|
* The browser supplies click-to-focus and accessible-name behavior with no synthetic layer.
|
|
621
|
-
* ## Ownership and lifetime
|
|
622
778
|
* The Scope owns label output/content; the referenced element remains separately owned.
|
|
623
779
|
* @since 1.0.0
|
|
624
|
-
* @category
|
|
780
|
+
* @category Field relationships
|
|
625
781
|
*/
|
|
626
782
|
export function Label(options, host) {
|
|
627
783
|
return Dom.renderHost()(options, host, labelProps(options), options.content, (props, content) => html `<label ...${props}>${content}</label>`);
|
|
@@ -630,14 +786,13 @@ function descriptionProps() {
|
|
|
630
786
|
return () => ({});
|
|
631
787
|
}
|
|
632
788
|
/**
|
|
633
|
-
* Renders
|
|
789
|
+
* Renders visible explanatory content in a neutral div.
|
|
790
|
+
*
|
|
634
791
|
* @remarks
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
* ## Ownership and lifetime
|
|
638
|
-
* The Scope owns the description host/content and no external control.
|
|
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.
|
|
639
794
|
* @since 1.0.0
|
|
640
|
-
* @category
|
|
795
|
+
* @category Field relationships
|
|
641
796
|
*/
|
|
642
797
|
export function Description(options, host) {
|
|
643
798
|
return Dom.renderHost()(options, host, descriptionProps(), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
|
|
@@ -649,15 +804,14 @@ function errorProps(options) {
|
|
|
649
804
|
});
|
|
650
805
|
}
|
|
651
806
|
/**
|
|
652
|
-
* Renders the
|
|
807
|
+
* Renders the named field message with a generated ID and alert role.
|
|
808
|
+
*
|
|
653
809
|
* @remarks
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* ## Ownership and lifetime
|
|
658
|
-
* The Scope owns the alert and state subscription; removing it does not remove form state.
|
|
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.
|
|
659
813
|
* @since 1.0.0
|
|
660
|
-
* @category
|
|
814
|
+
* @category Field relationships
|
|
661
815
|
*/
|
|
662
816
|
export function Error(options, host) {
|
|
663
817
|
const content = RefSubject.map(options.state, (state) => state.errors[options.name] ?? "");
|
|
@@ -669,12 +823,10 @@ function submitProps() {
|
|
|
669
823
|
/**
|
|
670
824
|
* Renders a native `type=submit` button.
|
|
671
825
|
* @remarks
|
|
672
|
-
* ## Why
|
|
673
826
|
* Keyboard activation, form association, and accessibility stay browser-standard.
|
|
674
|
-
* ## Ownership and lifetime
|
|
675
827
|
* The Scope owns the button/content; the surrounding Form owns submission sequencing.
|
|
676
828
|
* @since 1.0.0
|
|
677
|
-
* @category
|
|
829
|
+
* @category Form actions
|
|
678
830
|
*/
|
|
679
831
|
export function Submit(options, host) {
|
|
680
832
|
return Dom.renderHost()(options, host, submitProps(), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
@@ -688,13 +840,11 @@ function resetProps(options) {
|
|
|
688
840
|
/**
|
|
689
841
|
* Renders a native reset button that restores Typed form defaults.
|
|
690
842
|
* @remarks
|
|
691
|
-
* ## Why
|
|
692
843
|
* It prevents the browser's independent control mutation and resets the single
|
|
693
844
|
* RefSubject source of truth, clearing errors, metadata, and submitting state.
|
|
694
|
-
* ## Ownership and lifetime
|
|
695
845
|
* The Scope owns the click handler/content. The supplied form state remains independently owned.
|
|
696
846
|
* @since 1.0.0
|
|
697
|
-
* @category
|
|
847
|
+
* @category Form actions
|
|
698
848
|
*/
|
|
699
849
|
export function Reset(options, host) {
|
|
700
850
|
return Dom.renderHost()(options, host, resetProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
@@ -705,12 +855,10 @@ function groupProps(options) {
|
|
|
705
855
|
/**
|
|
706
856
|
* Renders an ARIA group with an optional accessible name.
|
|
707
857
|
* @remarks
|
|
708
|
-
* ## Why
|
|
709
858
|
* Related controls can expose their relationship without a framework-specific wrapper.
|
|
710
|
-
* ## Ownership and lifetime
|
|
711
859
|
* The Scope owns host/content; child controls retain their own DOM/state contracts.
|
|
712
860
|
* @since 1.0.0
|
|
713
|
-
* @category
|
|
861
|
+
* @category Field relationships
|
|
714
862
|
*/
|
|
715
863
|
export function Group(options, host) {
|
|
716
864
|
return Dom.renderHost()(options, host, groupProps(options), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
|
|
@@ -724,12 +872,10 @@ function pushProps(options) {
|
|
|
724
872
|
/**
|
|
725
873
|
* Renders a button that appends one item to an array field.
|
|
726
874
|
* @remarks
|
|
727
|
-
* ## Why
|
|
728
875
|
* Array mutation is immutable, typed, and marks the field dirty/touched.
|
|
729
|
-
* ## Ownership and lifetime
|
|
730
876
|
* The Scope owns the button handler/content; state may outlive the button.
|
|
731
877
|
* @since 1.0.0
|
|
732
|
-
* @category
|
|
878
|
+
* @category Form actions
|
|
733
879
|
*/
|
|
734
880
|
export function Push(options, host) {
|
|
735
881
|
return Dom.renderHost()(options, host, pushProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
@@ -743,25 +889,23 @@ function removeProps(options) {
|
|
|
743
889
|
/**
|
|
744
890
|
* Renders a button that removes one array item by index.
|
|
745
891
|
* @remarks
|
|
746
|
-
* ## Why
|
|
747
892
|
* Array mutation is immutable, typed, and marks the field dirty/touched.
|
|
748
|
-
* ## Ownership and lifetime
|
|
749
893
|
* The Scope owns the button handler/content; state may outlive the button.
|
|
750
894
|
* @since 1.0.0
|
|
751
|
-
* @category
|
|
895
|
+
* @category Form actions
|
|
752
896
|
*/
|
|
753
897
|
export function Remove(options, host) {
|
|
754
898
|
return Dom.renderHost()(options, host, removeProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
755
899
|
}
|
|
756
900
|
/**
|
|
757
|
-
*
|
|
901
|
+
* Assigns one decoded field value and updates dirty/touched metadata.
|
|
902
|
+
*
|
|
758
903
|
* @remarks
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
* The returned Effect mutates only the supplied RefSubject when run.
|
|
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.
|
|
763
907
|
* @since 1.0.0
|
|
764
|
-
* @category
|
|
908
|
+
* @category State transitions
|
|
765
909
|
*/
|
|
766
910
|
export function setValue(state, key, value) {
|
|
767
911
|
return updateDecodedValue(state, key, value);
|
|
@@ -782,15 +926,13 @@ function updateRecord(values, key, value) {
|
|
|
782
926
|
return updated;
|
|
783
927
|
}
|
|
784
928
|
/**
|
|
785
|
-
* Restores default values and clears errors,
|
|
929
|
+
* Restores default values and clears errors, metadata, and submitting.
|
|
930
|
+
*
|
|
786
931
|
* @remarks
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
* and test observer consistent with the source of truth.
|
|
790
|
-
* ## Ownership and lifetime
|
|
791
|
-
* The returned Effect performs one update when run and requires the same services as `state`.
|
|
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.
|
|
792
934
|
* @since 1.0.0
|
|
793
|
-
* @category
|
|
935
|
+
* @category State transitions
|
|
794
936
|
*/
|
|
795
937
|
export function reset(state) {
|
|
796
938
|
return RefSubject.update(state, (current) => ({
|
|
@@ -799,17 +941,15 @@ export function reset(state) {
|
|
|
799
941
|
errors: {},
|
|
800
942
|
meta: {},
|
|
801
943
|
submitting: false,
|
|
802
|
-
}));
|
|
944
|
+
})).pipe(Effect.tap(() => Effect.suspend(() => maskedInputResets.get(state)?.signal.onSuccess(undefined) ?? Effect.void)));
|
|
803
945
|
}
|
|
804
946
|
/**
|
|
805
947
|
* Appends one item to an array field and marks it dirty and touched.
|
|
806
948
|
* @remarks
|
|
807
|
-
* ## Why
|
|
808
949
|
* The immutable state transition is usable in tests, commands, or any renderer.
|
|
809
|
-
* ## Ownership and lifetime
|
|
810
950
|
* The returned Effect performs one RefSubject update when run.
|
|
811
951
|
* @since 1.0.0
|
|
812
|
-
* @category
|
|
952
|
+
* @category State transitions
|
|
813
953
|
*/
|
|
814
954
|
export function pushValue(state, name, value) {
|
|
815
955
|
return RefSubject.update(state, (current) => {
|
|
@@ -825,12 +965,10 @@ export function pushValue(state, name, value) {
|
|
|
825
965
|
/**
|
|
826
966
|
* Removes one item by index from an array field and marks it dirty and touched.
|
|
827
967
|
* @remarks
|
|
828
|
-
* ## Why
|
|
829
968
|
* The transition is explicit and renderer-independent; an out-of-range index leaves values unchanged.
|
|
830
|
-
* ## Ownership and lifetime
|
|
831
969
|
* The returned Effect performs one RefSubject update when run.
|
|
832
970
|
* @since 1.0.0
|
|
833
|
-
* @category
|
|
971
|
+
* @category State transitions
|
|
834
972
|
*/
|
|
835
973
|
export function removeValue(state, name, index) {
|
|
836
974
|
return RefSubject.update(state, (current) => {
|
|
@@ -860,16 +998,14 @@ function formProps(options) {
|
|
|
860
998
|
});
|
|
861
999
|
}
|
|
862
1000
|
/**
|
|
863
|
-
* Renders a native form and provides its state to
|
|
1001
|
+
* Renders a native form and provides its state to bound descendants.
|
|
1002
|
+
*
|
|
864
1003
|
* @remarks
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
* The root Scope owns its listeners/content and `CurrentForm` service. A valid
|
|
871
|
-
* submit Effect runs in that lifetime; `submitting` is cleared in finalization
|
|
872
|
-
* even on failure or interruption. Hydration does not itself attach client UI.
|
|
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
|
+
*
|
|
873
1009
|
* @example
|
|
874
1010
|
* ```ts
|
|
875
1011
|
* import { Form, Submit, TextInput, makeState } from "@typed/ui/Form"
|
|
@@ -887,7 +1023,7 @@ function formProps(options) {
|
|
|
887
1023
|
* })
|
|
888
1024
|
* ```
|
|
889
1025
|
* @since 1.0.0
|
|
890
|
-
* @category
|
|
1026
|
+
* @category Form roots
|
|
891
1027
|
*/
|
|
892
1028
|
export function Form(options, host) {
|
|
893
1029
|
const rendered = Dom.renderHost()(options, host, formProps(options), options.content, (props, content) => html `<form ...${props}>${content}</form>`);
|
|
@@ -896,32 +1032,51 @@ export function Form(options, host) {
|
|
|
896
1032
|
});
|
|
897
1033
|
}
|
|
898
1034
|
/**
|
|
899
|
-
*
|
|
1035
|
+
* Binds a native form component family to one Struct codec.
|
|
1036
|
+
*
|
|
900
1037
|
* @remarks
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* returns a hydrated RefSubject. `Root` provides that state through Effect
|
|
907
|
-
* context only for its render lifetime; controls borrow it.
|
|
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
|
+
*
|
|
908
1043
|
* @example
|
|
909
1044
|
* ```ts
|
|
910
|
-
* import {
|
|
911
|
-
* import {
|
|
912
|
-
* import {
|
|
1045
|
+
* import { Schema } from "effect";
|
|
1046
|
+
* import { html } from "@typed/template";
|
|
1047
|
+
* import { RefSubject } from "@typed/fx";
|
|
1048
|
+
* import { component } from "@typed/ui/Component";
|
|
1049
|
+
* import * as Form from "@typed/ui/Form";
|
|
913
1050
|
*
|
|
914
|
-
* const
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
*
|
|
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({
|
|
918
1061
|
* form,
|
|
919
|
-
* content:
|
|
920
|
-
*
|
|
921
|
-
* })
|
|
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
|
+
* });
|
|
922
1077
|
* ```
|
|
923
1078
|
* @since 1.0.0
|
|
924
|
-
* @category
|
|
1079
|
+
* @category Schema-bound components
|
|
925
1080
|
*/
|
|
926
1081
|
export function make(codec) {
|
|
927
1082
|
const state = (values, options = {}) => makeState(codec, { ...options, values });
|