@typed/ui 1.0.0-beta.3 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -9
- package/dist/Alert.d.ts +78 -0
- package/dist/Alert.d.ts.map +1 -0
- package/dist/Alert.js +37 -0
- package/dist/Button.d.ts +144 -0
- package/dist/Button.d.ts.map +1 -0
- package/dist/Button.js +38 -0
- package/dist/Carousel.d.ts +547 -0
- package/dist/Carousel.d.ts.map +1 -0
- package/dist/Carousel.js +387 -0
- package/dist/Checkbox.d.ts +203 -0
- package/dist/Checkbox.d.ts.map +1 -0
- package/dist/Checkbox.js +140 -0
- package/dist/Collection.d.ts +254 -0
- package/dist/Collection.d.ts.map +1 -0
- package/dist/Collection.js +218 -0
- package/dist/Combobox.d.ts +516 -0
- package/dist/Combobox.d.ts.map +1 -0
- package/dist/Combobox.js +371 -0
- package/dist/Component.d.ts +127 -0
- package/dist/Component.d.ts.map +1 -0
- package/dist/Component.js +60 -0
- package/dist/Composite.d.ts +823 -0
- package/dist/Composite.d.ts.map +1 -0
- package/dist/Composite.js +615 -0
- package/dist/Dialog.d.ts +544 -0
- package/dist/Dialog.d.ts.map +1 -0
- package/dist/Dialog.js +355 -0
- package/dist/Disclosure.d.ts +219 -0
- package/dist/Disclosure.d.ts.map +1 -0
- package/dist/Disclosure.js +128 -0
- package/dist/Dom/Events.d.ts +122 -0
- package/dist/Dom/Events.d.ts.map +1 -0
- package/dist/Dom/Events.js +192 -0
- package/dist/Dom/Props.d.ts +161 -0
- package/dist/Dom/Props.d.ts.map +1 -0
- package/dist/Dom/Props.js +110 -0
- package/dist/Dom/Refs.d.ts +58 -0
- package/dist/Dom/Refs.d.ts.map +1 -0
- package/dist/Dom/Refs.js +61 -0
- package/dist/Dom/Render.d.ts +59 -0
- package/dist/Dom/Render.d.ts.map +1 -0
- package/dist/Dom/Render.js +71 -0
- package/dist/Dom/Types.d.ts +570 -0
- package/dist/Dom/Types.d.ts.map +1 -0
- package/dist/Dom/Types.js +1 -0
- package/dist/Dom/index.d.ts +20 -0
- package/dist/Dom/index.d.ts.map +1 -0
- package/dist/Dom/index.js +8 -0
- package/dist/Dom.d.ts +14 -0
- package/dist/Dom.d.ts.map +1 -0
- package/dist/Dom.js +13 -0
- package/dist/Focusable.d.ts +85 -0
- package/dist/Focusable.d.ts.map +1 -0
- package/dist/Focusable.js +35 -0
- package/dist/Form.d.ts +1695 -0
- package/dist/Form.d.ts.map +1 -0
- package/dist/Form.js +987 -0
- package/dist/Grid.d.ts +569 -0
- package/dist/Grid.d.ts.map +1 -0
- package/dist/Grid.js +379 -0
- package/dist/Group.d.ts +147 -0
- package/dist/Group.d.ts.map +1 -0
- package/dist/Group.js +63 -0
- package/dist/Heading.d.ts +86 -0
- package/dist/Heading.d.ts.map +1 -0
- package/dist/Heading.js +48 -0
- package/dist/Hovercard.d.ts +297 -0
- package/dist/Hovercard.d.ts.map +1 -0
- package/dist/Hovercard.js +188 -0
- package/dist/HttpRouter.d.ts +129 -6
- package/dist/HttpRouter.d.ts.map +1 -1
- package/dist/HttpRouter.js +197 -54
- package/dist/Link.d.ts +67 -28
- package/dist/Link.d.ts.map +1 -1
- package/dist/Link.js +91 -37
- package/dist/Listbox.d.ts +437 -0
- package/dist/Listbox.d.ts.map +1 -0
- package/dist/Listbox.js +316 -0
- package/dist/Menu.d.ts +972 -0
- package/dist/Menu.d.ts.map +1 -0
- package/dist/Menu.js +731 -0
- package/dist/Menubar.d.ts +367 -0
- package/dist/Menubar.d.ts.map +1 -0
- package/dist/Menubar.js +265 -0
- package/dist/Meter.d.ts +217 -0
- package/dist/Meter.d.ts.map +1 -0
- package/dist/Meter.js +94 -0
- package/dist/NativeDetails.d.ts +41 -0
- package/dist/NativeDetails.d.ts.map +1 -0
- package/dist/NativeDetails.js +40 -0
- package/dist/NativeDialog.d.ts +64 -0
- package/dist/NativeDialog.d.ts.map +1 -0
- package/dist/NativeDialog.js +50 -0
- package/dist/NativePopover.d.ts +41 -0
- package/dist/NativePopover.d.ts.map +1 -0
- package/dist/NativePopover.js +46 -0
- package/dist/Popover.d.ts +241 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/Popover.js +141 -0
- package/dist/RadioGroup.d.ts +432 -0
- package/dist/RadioGroup.d.ts.map +1 -0
- package/dist/RadioGroup.js +291 -0
- package/dist/Role.d.ts +64 -0
- package/dist/Role.d.ts.map +1 -0
- package/dist/Role.js +27 -0
- package/dist/Select.d.ts +529 -0
- package/dist/Select.d.ts.map +1 -0
- package/dist/Select.js +407 -0
- package/dist/Separator.d.ts +54 -0
- package/dist/Separator.d.ts.map +1 -0
- package/dist/Separator.js +26 -0
- package/dist/Slider.d.ts +188 -0
- package/dist/Slider.d.ts.map +1 -0
- package/dist/Slider.js +96 -0
- package/dist/SpinButton.d.ts +188 -0
- package/dist/SpinButton.d.ts.map +1 -0
- package/dist/SpinButton.js +96 -0
- package/dist/Storybook.d.ts +76 -0
- package/dist/Storybook.d.ts.map +1 -0
- package/dist/Storybook.js +102 -0
- package/dist/Switch.d.ts +187 -0
- package/dist/Switch.d.ts.map +1 -0
- package/dist/Switch.js +116 -0
- package/dist/Tab.d.ts +26 -0
- package/dist/Tab.d.ts.map +1 -0
- package/dist/Tab.js +25 -0
- package/dist/Tabs.d.ts +591 -0
- package/dist/Tabs.d.ts.map +1 -0
- package/dist/Tabs.js +346 -0
- package/dist/Toolbar.d.ts +366 -0
- package/dist/Toolbar.d.ts.map +1 -0
- package/dist/Toolbar.js +245 -0
- package/dist/Tooltip.d.ts +296 -0
- package/dist/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip.js +172 -0
- package/dist/Tree.d.ts +591 -0
- package/dist/Tree.d.ts.map +1 -0
- package/dist/Tree.js +434 -0
- package/dist/TreeGrid.d.ts +645 -0
- package/dist/TreeGrid.d.ts.map +1 -0
- package/dist/TreeGrid.js +420 -0
- package/dist/VisuallyHidden.d.ts +54 -0
- package/dist/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden.js +27 -0
- package/dist/WindowSplitter.d.ts +376 -0
- package/dist/WindowSplitter.d.ts.map +1 -0
- package/dist/WindowSplitter.js +222 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -0
- package/package.json +42 -15
- package/src/HttpRouter.test.ts +0 -294
- package/src/HttpRouter.ts +0 -168
- package/src/Link.test.ts +0 -84
- package/src/Link.ts +0 -107
- package/src/index.ts +0 -2
package/dist/Form.js
ADDED
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Context from "effect/Context";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import * as SchemaIssue from "effect/SchemaIssue";
|
|
5
|
+
import * as SchemaTransformation from "effect/SchemaTransformation";
|
|
6
|
+
import { Fx as FxApi, RefSubject } from "@typed/fx";
|
|
7
|
+
import { EventHandler, html, } from "@typed/template";
|
|
8
|
+
import * as Dom from "./Dom.js";
|
|
9
|
+
let nextFormId = 0;
|
|
10
|
+
/**
|
|
11
|
+
* Effect context service used by schema-bound form controls.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* ## Why
|
|
15
|
+
* Bound controls avoid threading `state` through every call while their
|
|
16
|
+
* service requirement remains visible in the Fx type.
|
|
17
|
+
*
|
|
18
|
+
* ## Ownership and lifetime
|
|
19
|
+
* `Form` provides the service for its child render lifetime; use outside that
|
|
20
|
+
* boundary fails with the ordinary Effect missing-service defect.
|
|
21
|
+
*
|
|
22
|
+
* @since 1.0.0
|
|
23
|
+
* @category services
|
|
24
|
+
*/
|
|
25
|
+
export const CurrentForm = Context.Service("@typed/ui/Form/CurrentForm");
|
|
26
|
+
function withCurrentForm() {
|
|
27
|
+
return (f) => FxApi.gen(function* () {
|
|
28
|
+
const current = yield* CurrentForm;
|
|
29
|
+
return f(current.state);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const FieldMetaSchema = Schema.Struct({
|
|
33
|
+
dirty: Schema.Boolean,
|
|
34
|
+
touched: Schema.Boolean,
|
|
35
|
+
});
|
|
36
|
+
function optionalFields(fields, value) {
|
|
37
|
+
return Object.fromEntries(Object.keys(fields).map((key) => [key, Schema.optionalKey(value)]));
|
|
38
|
+
}
|
|
39
|
+
function emptyOptionalFields() {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds the serializable schema for a form's hydrated state.
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* ## Why
|
|
47
|
+
* The hydration payload needs validation independent from runtime-only field codecs.
|
|
48
|
+
*
|
|
49
|
+
* ## Ownership and lifetime
|
|
50
|
+
* Pure schema construction; the returned Schema acquires no Scope or subscription.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* import { StateSchema } from "@typed/ui/Form"
|
|
55
|
+
* import { Schema } from "effect"
|
|
56
|
+
*
|
|
57
|
+
* const codec = Schema.Struct({ email: Schema.String })
|
|
58
|
+
* const stateCodec = StateSchema(codec)
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* @since 1.0.0
|
|
62
|
+
* @category schemas
|
|
63
|
+
*/
|
|
64
|
+
export function StateSchema(codec) {
|
|
65
|
+
return Schema.Struct({
|
|
66
|
+
values: codec,
|
|
67
|
+
defaultValues: codec,
|
|
68
|
+
errors: Schema.Struct(optionalFields(codec.fields, Schema.String)),
|
|
69
|
+
meta: Schema.Struct(optionalFields(codec.fields, FieldMetaSchema)),
|
|
70
|
+
submitting: Schema.Boolean,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Creates a Scope-owned hydrated form RefSubject from a Struct codec.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* ## Why
|
|
78
|
+
* One constructor establishes values, defaults, validation state, field codecs,
|
|
79
|
+
* and hydration identity consistently.
|
|
80
|
+
*
|
|
81
|
+
* ## Ownership and lifetime
|
|
82
|
+
* Requires `Scope.Scope`. Provide an explicit `id` during SSR; the counter-based
|
|
83
|
+
* fallback is process/order dependent. Only state data hydrates—`codec` and
|
|
84
|
+
* `fields` are reattached from the live Struct on each runtime.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { makeState } from "@typed/ui/Form"
|
|
89
|
+
* import { Effect, Schema } from "effect"
|
|
90
|
+
*
|
|
91
|
+
* const codec = Schema.Struct({ email: Schema.String })
|
|
92
|
+
* const program = Effect.gen(function* () {
|
|
93
|
+
* const state = yield* makeState(codec, { id: "signup", values: { email: "" } })
|
|
94
|
+
* return state
|
|
95
|
+
* })
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* @since 1.0.0
|
|
99
|
+
* @category constructors
|
|
100
|
+
*/
|
|
101
|
+
export function makeState(codec, initial) {
|
|
102
|
+
const id = initial.id ?? `typed-form-${++nextFormId}`;
|
|
103
|
+
return Effect.map(RefSubject.hydrate(StateSchema(codec), {
|
|
104
|
+
values: initial.values,
|
|
105
|
+
defaultValues: initial.defaultValues ?? initial.values,
|
|
106
|
+
errors: initial.errors ?? emptyOptionalFields(),
|
|
107
|
+
meta: initial.meta ?? emptyOptionalFields(),
|
|
108
|
+
submitting: initial.submitting ?? false,
|
|
109
|
+
}), (state) => Object.assign(state, { codec, fields: codec.fields, id }));
|
|
110
|
+
}
|
|
111
|
+
function setFieldError(state, key, error) {
|
|
112
|
+
return RefSubject.update(state, (current) => {
|
|
113
|
+
const errors = { ...current.errors };
|
|
114
|
+
if (error === undefined)
|
|
115
|
+
delete errors[key];
|
|
116
|
+
else
|
|
117
|
+
errors[key] = error;
|
|
118
|
+
return { ...current, errors };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function fieldErrorId(state, name) {
|
|
122
|
+
return `${state.id}-${encodeURIComponent(name)}-error`;
|
|
123
|
+
}
|
|
124
|
+
function decodeField(state, name, codec, value) {
|
|
125
|
+
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)));
|
|
126
|
+
}
|
|
127
|
+
function decodeUpdatedField(state, name, encoded) {
|
|
128
|
+
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
|
+
}
|
|
130
|
+
function inputProps(options, type, codec) {
|
|
131
|
+
return () => ({
|
|
132
|
+
type,
|
|
133
|
+
name: options.name,
|
|
134
|
+
"aria-describedby": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined
|
|
135
|
+
? undefined
|
|
136
|
+
: fieldErrorId(options.state, options.name)),
|
|
137
|
+
"aria-invalid": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined ? undefined : true),
|
|
138
|
+
".value": RefSubject.mapEffect(options.state, (state) => Schema.encodeUnknownEffect(codec)(state.values[options.name])),
|
|
139
|
+
oninput: EventHandler.make(Effect.fn((event) => decodeField(options.state, options.name, codec, Dom.currentTarget(event).value))),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function renderInput(options, host, type, defaultCodec) {
|
|
143
|
+
const codec = options.codec ?? defaultCodec;
|
|
144
|
+
return Dom.renderHost()(options, host, inputProps(options, type, codec), "", (props) => html `<input ...${props} />`);
|
|
145
|
+
}
|
|
146
|
+
function makeInput(type, codec) {
|
|
147
|
+
return function (options, host) {
|
|
148
|
+
return renderInput(options, host, type, codec);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function makeSchemaBoundInput(formCodec, type) {
|
|
152
|
+
return function (options, host) {
|
|
153
|
+
return withCurrentForm()((state) => {
|
|
154
|
+
const inputOptions = { ...options, state };
|
|
155
|
+
const fieldCodec = formCodec.fields[options.name];
|
|
156
|
+
return renderInput(inputOptions, host, type, fieldCodec);
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Binds a native `input[type=text]` to a string field.
|
|
162
|
+
*
|
|
163
|
+
* @remarks
|
|
164
|
+
* ## Why
|
|
165
|
+
* The control uses the browser's real input event and a Schema codec while
|
|
166
|
+
* keeping state independently testable.
|
|
167
|
+
*
|
|
168
|
+
* ## Ownership and lifetime
|
|
169
|
+
* The control Scope owns DOM listeners/subscriptions; the supplied `FormState`
|
|
170
|
+
* may outlive the rendered input. A custom host must apply all merged props.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```ts
|
|
174
|
+
* import { TextInput, makeState } from "@typed/ui/Form"
|
|
175
|
+
* import { Effect, Schema } from "effect"
|
|
176
|
+
*
|
|
177
|
+
* const codec = Schema.Struct({ name: Schema.String })
|
|
178
|
+
* const input = Effect.gen(function* () {
|
|
179
|
+
* const state = yield* makeState(codec, { values: { name: "" } })
|
|
180
|
+
* return TextInput({ state, name: "name" })
|
|
181
|
+
* })
|
|
182
|
+
* ```
|
|
183
|
+
*
|
|
184
|
+
* @since 1.0.0
|
|
185
|
+
* @category components
|
|
186
|
+
*/
|
|
187
|
+
export const TextInput = makeInput("text", Schema.String);
|
|
188
|
+
/**
|
|
189
|
+
* Binds a native search input to a string field.
|
|
190
|
+
* @remarks
|
|
191
|
+
* ## Why
|
|
192
|
+
* 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
|
+
* @since 1.0.0
|
|
196
|
+
* @category components
|
|
197
|
+
*/
|
|
198
|
+
export const SearchInput = makeInput("search", Schema.String);
|
|
199
|
+
/**
|
|
200
|
+
* Binds a native email input to a string field.
|
|
201
|
+
* @remarks
|
|
202
|
+
* ## Why
|
|
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
|
+
* @since 1.0.0
|
|
207
|
+
* @category components
|
|
208
|
+
*/
|
|
209
|
+
export const EmailInput = makeInput("email", Schema.String);
|
|
210
|
+
/**
|
|
211
|
+
* Binds a native URL input to a string field.
|
|
212
|
+
* @remarks
|
|
213
|
+
* ## Why
|
|
214
|
+
* 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
|
+
* @since 1.0.0
|
|
218
|
+
* @category components
|
|
219
|
+
*/
|
|
220
|
+
export const UrlInput = makeInput("url", Schema.String);
|
|
221
|
+
/**
|
|
222
|
+
* Binds a native telephone input to a string field.
|
|
223
|
+
* @remarks
|
|
224
|
+
* ## Why
|
|
225
|
+
* Preserves platform telephone keyboards and autocomplete behavior.
|
|
226
|
+
* ## Ownership and lifetime
|
|
227
|
+
* DOM work is control-Scope-owned; form state remains independently owned.
|
|
228
|
+
* @since 1.0.0
|
|
229
|
+
* @category components
|
|
230
|
+
*/
|
|
231
|
+
export const TelInput = makeInput("tel", Schema.String);
|
|
232
|
+
/**
|
|
233
|
+
* Binds a native password input to a string field.
|
|
234
|
+
* @remarks
|
|
235
|
+
* ## Why
|
|
236
|
+
* 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
|
+
* @since 1.0.0
|
|
240
|
+
* @category components
|
|
241
|
+
*/
|
|
242
|
+
export const PasswordInput = makeInput("password", Schema.String);
|
|
243
|
+
/**
|
|
244
|
+
* Binds a native hidden input to a string field.
|
|
245
|
+
* @remarks
|
|
246
|
+
* ## Why
|
|
247
|
+
* 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
|
+
* @since 1.0.0
|
|
251
|
+
* @category components
|
|
252
|
+
*/
|
|
253
|
+
export const HiddenInput = makeInput("hidden", Schema.String);
|
|
254
|
+
/**
|
|
255
|
+
* Binds a native color input to a string field.
|
|
256
|
+
* @remarks
|
|
257
|
+
* ## Why
|
|
258
|
+
* 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
|
+
* @since 1.0.0
|
|
262
|
+
* @category components
|
|
263
|
+
*/
|
|
264
|
+
export const ColorInput = makeInput("color", Schema.String);
|
|
265
|
+
/**
|
|
266
|
+
* Binds a native time input to a string field.
|
|
267
|
+
* @remarks
|
|
268
|
+
* ## Why
|
|
269
|
+
* 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
|
+
* @since 1.0.0
|
|
273
|
+
* @category components
|
|
274
|
+
*/
|
|
275
|
+
export const TimeInput = makeInput("time", Schema.String);
|
|
276
|
+
/**
|
|
277
|
+
* Binds a native local date-time input to a string field.
|
|
278
|
+
* @remarks
|
|
279
|
+
* ## Why
|
|
280
|
+
* 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
|
+
* @since 1.0.0
|
|
284
|
+
* @category components
|
|
285
|
+
*/
|
|
286
|
+
export const DateTimeLocalInput = makeInput("datetime-local", Schema.String);
|
|
287
|
+
/**
|
|
288
|
+
* Binds a native month input to a string field.
|
|
289
|
+
* @remarks
|
|
290
|
+
* ## Why
|
|
291
|
+
* 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
|
+
* @since 1.0.0
|
|
295
|
+
* @category components
|
|
296
|
+
*/
|
|
297
|
+
export const MonthInput = makeInput("month", Schema.String);
|
|
298
|
+
/**
|
|
299
|
+
* Binds a native week input to a string field.
|
|
300
|
+
* @remarks
|
|
301
|
+
* ## Why
|
|
302
|
+
* 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
|
+
* @since 1.0.0
|
|
306
|
+
* @category components
|
|
307
|
+
*/
|
|
308
|
+
export const WeekInput = makeInput("week", Schema.String);
|
|
309
|
+
/**
|
|
310
|
+
* Binds a native number input to a finite number field.
|
|
311
|
+
* @remarks
|
|
312
|
+
* ## Why
|
|
313
|
+
* `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
|
+
* @since 1.0.0
|
|
317
|
+
* @category components
|
|
318
|
+
*/
|
|
319
|
+
export const NumberInput = makeInput("number", Schema.FiniteFromString);
|
|
320
|
+
/**
|
|
321
|
+
* Binds a native range input to a finite number field.
|
|
322
|
+
* @remarks
|
|
323
|
+
* ## Why
|
|
324
|
+
* 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
|
+
* @since 1.0.0
|
|
328
|
+
* @category components
|
|
329
|
+
*/
|
|
330
|
+
export const RangeInput = makeInput("range", Schema.FiniteFromString);
|
|
331
|
+
/**
|
|
332
|
+
* Binds a native date input to a `Date` field.
|
|
333
|
+
* @remarks
|
|
334
|
+
* ## Why
|
|
335
|
+
* `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
|
+
* @since 1.0.0
|
|
339
|
+
* @category components
|
|
340
|
+
*/
|
|
341
|
+
export const DateInput = makeInput("date", Schema.DateFromString);
|
|
342
|
+
/**
|
|
343
|
+
* Converts native FormData to a record and preserves repeated fields as arrays.
|
|
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.
|
|
350
|
+
* @example
|
|
351
|
+
* ```ts
|
|
352
|
+
* import { formDataToRecord } from "@typed/ui/Form"
|
|
353
|
+
*
|
|
354
|
+
* const data = new FormData()
|
|
355
|
+
* data.append("tag", "one")
|
|
356
|
+
* data.append("tag", "two")
|
|
357
|
+
* const record = formDataToRecord(data)
|
|
358
|
+
* ```
|
|
359
|
+
* @since 1.0.0
|
|
360
|
+
* @category conversions
|
|
361
|
+
*/
|
|
362
|
+
export function formDataToRecord(data) {
|
|
363
|
+
const result = {};
|
|
364
|
+
for (const [name, value] of data.entries()) {
|
|
365
|
+
const existing = result[name];
|
|
366
|
+
result[name] =
|
|
367
|
+
existing === undefined
|
|
368
|
+
? value
|
|
369
|
+
: Array.isArray(existing)
|
|
370
|
+
? [...existing, value]
|
|
371
|
+
: [existing, value];
|
|
372
|
+
}
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Decodes native FormData through an Effect Schema codec.
|
|
377
|
+
* @remarks
|
|
378
|
+
* ## Why
|
|
379
|
+
* Browser serialization, repeated values, Files, and typed validation meet at
|
|
380
|
+
* one explicit fallible boundary.
|
|
381
|
+
* ## Ownership and lifetime
|
|
382
|
+
* The returned Effect is lazy and owns no browser resource; it references File
|
|
383
|
+
* objects present in the supplied FormData.
|
|
384
|
+
* @example
|
|
385
|
+
* ```ts
|
|
386
|
+
* import { decodeFormData } from "@typed/ui/Form"
|
|
387
|
+
* import { Schema } from "effect"
|
|
388
|
+
*
|
|
389
|
+
* const decode = decodeFormData(Schema.Struct({ name: Schema.String }), new FormData())
|
|
390
|
+
* ```
|
|
391
|
+
* @since 1.0.0
|
|
392
|
+
* @category conversions
|
|
393
|
+
*/
|
|
394
|
+
export function decodeFormData(codec, data) {
|
|
395
|
+
return Schema.decodeEffect(codec)(formDataToRecord(data));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Validates current form values and synchronizes decoded values or field errors.
|
|
399
|
+
* @remarks
|
|
400
|
+
* ## Why
|
|
401
|
+
* Submission needs one whole-form schema check in addition to incremental field decoding.
|
|
402
|
+
* ## Ownership and lifetime
|
|
403
|
+
* The returned Effect updates the supplied state when run. On success it clears
|
|
404
|
+
* errors; on failure it records messages and re-fails with `SchemaError`.
|
|
405
|
+
* @since 1.0.0
|
|
406
|
+
* @category validation
|
|
407
|
+
*/
|
|
408
|
+
export function validate(state) {
|
|
409
|
+
return Effect.flatMap(state, (current) => Schema.decodeUnknownEffect(Schema.toType(state.codec))(current.values)).pipe(Effect.flatMap((values) => Effect.as(RefSubject.update(state, (current) => ({
|
|
410
|
+
...current,
|
|
411
|
+
values,
|
|
412
|
+
errors: {},
|
|
413
|
+
})), values)), Effect.catch((error) => RefSubject.update(state, (current) => ({
|
|
414
|
+
...current,
|
|
415
|
+
errors: formErrors(current.values, current.errors, error.message),
|
|
416
|
+
})).pipe(Effect.andThen(Effect.fail(error)))));
|
|
417
|
+
}
|
|
418
|
+
function formErrors(values, errors, error) {
|
|
419
|
+
const next = { ...errors };
|
|
420
|
+
for (const key of Object.keys(values))
|
|
421
|
+
Reflect.set(next, key, error);
|
|
422
|
+
return next;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Creates a named, Schema-decoded mask slot.
|
|
426
|
+
* @remarks
|
|
427
|
+
* ## Why
|
|
428
|
+
* Length and character constraints are expressed beside the codec that owns conversion.
|
|
429
|
+
* ## Ownership and lifetime
|
|
430
|
+
* Pure constructor; the returned descriptor retains the codec but acquires no Scope.
|
|
431
|
+
* @example
|
|
432
|
+
* ```ts
|
|
433
|
+
* import { slot } from "@typed/ui/Form"
|
|
434
|
+
* import { Schema } from "effect"
|
|
435
|
+
*
|
|
436
|
+
* const areaCode = slot("area", Schema.String, { length: 3, charset: /[0-9]/ })
|
|
437
|
+
* ```
|
|
438
|
+
* @since 1.0.0
|
|
439
|
+
* @category constructors
|
|
440
|
+
*/
|
|
441
|
+
export function slot(name, codec, options = {}) {
|
|
442
|
+
return { _tag: "MaskSlot", name, codec, ...options };
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Builds a bidirectional Schema codec from literal text and named slots.
|
|
446
|
+
* @remarks
|
|
447
|
+
* ## Why
|
|
448
|
+
* Display formatting and decoding share one ordered specification and produce
|
|
449
|
+
* ordinary Schema issues on invalid length, characters, literals, or slot values.
|
|
450
|
+
* ## Ownership and lifetime
|
|
451
|
+
* Pure codec construction. Decode/encode Effects are lazy and Scope-free.
|
|
452
|
+
* @example
|
|
453
|
+
* ```ts
|
|
454
|
+
* import { mask, slot } from "@typed/ui/Form"
|
|
455
|
+
* import { Schema } from "effect"
|
|
456
|
+
*
|
|
457
|
+
* const phone = mask("(", slot("area", Schema.String, { length: 3 }), ") ",
|
|
458
|
+
* slot("number", Schema.String, { length: 7 }))
|
|
459
|
+
* ```
|
|
460
|
+
* @since 1.0.0
|
|
461
|
+
* @category schemas
|
|
462
|
+
*/
|
|
463
|
+
export function mask(...parts) {
|
|
464
|
+
const valueSchema = Schema.declare((value) => typeof value === "object" &&
|
|
465
|
+
value !== null &&
|
|
466
|
+
parts.every((part) => typeof part === "string" || Reflect.has(value, part.name)));
|
|
467
|
+
return Schema.String.pipe(Schema.decodeTo(valueSchema, SchemaTransformation.transformOrFail({
|
|
468
|
+
decode: (display, options) => decodeMask(parts, display, options),
|
|
469
|
+
encode: (value, options) => encodeMask(parts, value, options),
|
|
470
|
+
})));
|
|
471
|
+
}
|
|
472
|
+
function decodeMask(parts, display, options) {
|
|
473
|
+
return Effect.gen(function* () {
|
|
474
|
+
const value = {};
|
|
475
|
+
let offset = 0;
|
|
476
|
+
for (let index = 0; index < parts.length; index++) {
|
|
477
|
+
const part = parts[index];
|
|
478
|
+
if (typeof part === "string") {
|
|
479
|
+
if (!display.startsWith(part, offset))
|
|
480
|
+
return yield* invalidMask(display, options);
|
|
481
|
+
offset += part.length;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const nextLiteral = parts.slice(index + 1).find((next) => typeof next === "string");
|
|
485
|
+
const end = part.length === undefined
|
|
486
|
+
? nextLiteral === undefined
|
|
487
|
+
? display.length
|
|
488
|
+
: display.indexOf(nextLiteral, offset)
|
|
489
|
+
: offset + part.length;
|
|
490
|
+
if (end < offset)
|
|
491
|
+
return yield* invalidMask(display, options);
|
|
492
|
+
const encoded = display.slice(offset, end);
|
|
493
|
+
if (encoded.length === 0 ||
|
|
494
|
+
(part.length !== undefined && encoded.length !== part.length) ||
|
|
495
|
+
[...encoded].some((character) => !matchesCharset(part, character)))
|
|
496
|
+
return yield* invalidMask(display, options);
|
|
497
|
+
const decoded = yield* Schema.decodeEffect(part.codec)(encoded).pipe(Effect.mapError(() => new SchemaIssue.InvalidValue({ message: `Invalid ${part.name}` }, display, options)));
|
|
498
|
+
Reflect.set(value, part.name, decoded);
|
|
499
|
+
offset = end;
|
|
500
|
+
}
|
|
501
|
+
if (offset !== display.length || !isMaskValue(parts, value))
|
|
502
|
+
return yield* invalidMask(display, options);
|
|
503
|
+
return value;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
function encodeMask(parts, value, options) {
|
|
507
|
+
return Effect.gen(function* () {
|
|
508
|
+
let display = "";
|
|
509
|
+
for (const part of parts) {
|
|
510
|
+
if (typeof part === "string") {
|
|
511
|
+
display += part;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const encoded = yield* Schema.encodeUnknownEffect(part.codec)(Reflect.get(value, part.name)).pipe(Effect.mapError(() => new SchemaIssue.InvalidValue({ message: `Invalid ${part.name}` }, value, options)));
|
|
515
|
+
if (encoded.length === 0 ||
|
|
516
|
+
(part.length !== undefined && encoded.length !== part.length) ||
|
|
517
|
+
[...encoded].some((character) => !matchesCharset(part, character)))
|
|
518
|
+
return yield* invalidMask(value, options);
|
|
519
|
+
display += encoded;
|
|
520
|
+
}
|
|
521
|
+
return display;
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function isMaskValue(parts, value) {
|
|
525
|
+
return (typeof value === "object" &&
|
|
526
|
+
value !== null &&
|
|
527
|
+
parts.every((part) => typeof part === "string" || Reflect.has(value, part.name)));
|
|
528
|
+
}
|
|
529
|
+
function matchesCharset(slot, character) {
|
|
530
|
+
if (slot.charset === undefined)
|
|
531
|
+
return true;
|
|
532
|
+
if (slot.charset instanceof RegExp) {
|
|
533
|
+
slot.charset.lastIndex = 0;
|
|
534
|
+
return slot.charset.test(character);
|
|
535
|
+
}
|
|
536
|
+
return slot.charset(character);
|
|
537
|
+
}
|
|
538
|
+
function invalidMask(input, options) {
|
|
539
|
+
return Effect.fail(new SchemaIssue.InvalidValue({ message: "Invalid mask" }, input, options));
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Binds a native text input to a structured mask value.
|
|
543
|
+
* @remarks
|
|
544
|
+
* ## Why
|
|
545
|
+
* The supplied Schema codec controls both display encoding and input decoding;
|
|
546
|
+
* failed edits update field errors rather than corrupting decoded state.
|
|
547
|
+
* ## Ownership and lifetime
|
|
548
|
+
* DOM listeners and reactive value binding live in the control Scope. The
|
|
549
|
+
* supplied state can be tested and retained without mounting this control.
|
|
550
|
+
* @since 1.0.0
|
|
551
|
+
* @category components
|
|
552
|
+
*/
|
|
553
|
+
export function MaskedInput(options, host) {
|
|
554
|
+
const { mask, ...inputOptions } = options;
|
|
555
|
+
return renderInput(inputOptions, host, "text", mask);
|
|
556
|
+
}
|
|
557
|
+
function checkboxProps(options) {
|
|
558
|
+
const checked = RefSubject.map(options.state, (state) => state.values[options.name] === true);
|
|
559
|
+
return () => ({
|
|
560
|
+
type: "checkbox",
|
|
561
|
+
name: options.name,
|
|
562
|
+
"aria-describedby": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined
|
|
563
|
+
? undefined
|
|
564
|
+
: fieldErrorId(options.state, options.name)),
|
|
565
|
+
"aria-invalid": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined ? undefined : true),
|
|
566
|
+
"?checked": checked,
|
|
567
|
+
".checked": checked,
|
|
568
|
+
onchange: EventHandler.make(Effect.fn((event) => decodeUpdatedField(options.state, options.name, Dom.currentTarget(event).checked))),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Binds a native checkbox to a boolean form field.
|
|
573
|
+
* @remarks
|
|
574
|
+
* ## Why
|
|
575
|
+
* Both the checked attribute and live property follow state, while the browser's
|
|
576
|
+
* real change event is decoded through the field codec.
|
|
577
|
+
* ## Ownership and lifetime
|
|
578
|
+
* The rendered Scope owns the input, listener, and subscriptions. A custom host
|
|
579
|
+
* must apply merged name, ARIA, checked, and change props.
|
|
580
|
+
* @since 1.0.0
|
|
581
|
+
* @category components
|
|
582
|
+
*/
|
|
583
|
+
export function Checkbox(options, host) {
|
|
584
|
+
return Dom.renderHost()(options, host, checkboxProps(options), "", (props) => html `<input ...${props} />`);
|
|
585
|
+
}
|
|
586
|
+
function selectProps(options) {
|
|
587
|
+
return () => ({
|
|
588
|
+
name: options.name,
|
|
589
|
+
"aria-describedby": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined
|
|
590
|
+
? undefined
|
|
591
|
+
: fieldErrorId(options.state, options.name)),
|
|
592
|
+
"aria-invalid": RefSubject.map(options.state, (state) => state.errors[options.name] === undefined ? undefined : true),
|
|
593
|
+
".value": RefSubject.map(options.state, (state) => String(state.values[options.name])),
|
|
594
|
+
onchange: EventHandler.make(Effect.fn((event) => decodeUpdatedField(options.state, options.name, Dom.currentTarget(event).value))),
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Binds a native select element to a string form field.
|
|
599
|
+
* @remarks
|
|
600
|
+
* ## Why
|
|
601
|
+
* Native keyboard, accessibility, option, and form semantics remain browser-owned.
|
|
602
|
+
* ## Ownership and lifetime
|
|
603
|
+
* The rendered Scope owns the select/content subscriptions. A custom host must
|
|
604
|
+
* preserve supplied name, ARIA, value, and change props.
|
|
605
|
+
* @since 1.0.0
|
|
606
|
+
* @category components
|
|
607
|
+
*/
|
|
608
|
+
export function Select(options, host) {
|
|
609
|
+
return Dom.renderHost()(options, host, selectProps(options), options.content, (props, content) => html `<select ...${props}>
|
|
610
|
+
${content}
|
|
611
|
+
</select>`);
|
|
612
|
+
}
|
|
613
|
+
function labelProps(options) {
|
|
614
|
+
return () => ({ for: options.for });
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Renders a native label with an explicit control relationship.
|
|
618
|
+
* @remarks
|
|
619
|
+
* ## Why
|
|
620
|
+
* The browser supplies click-to-focus and accessible-name behavior with no synthetic layer.
|
|
621
|
+
* ## Ownership and lifetime
|
|
622
|
+
* The Scope owns label output/content; the referenced element remains separately owned.
|
|
623
|
+
* @since 1.0.0
|
|
624
|
+
* @category components
|
|
625
|
+
*/
|
|
626
|
+
export function Label(options, host) {
|
|
627
|
+
return Dom.renderHost()(options, host, labelProps(options), options.content, (props, content) => html `<label ...${props}>${content}</label>`);
|
|
628
|
+
}
|
|
629
|
+
function descriptionProps() {
|
|
630
|
+
return () => ({});
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Renders form description content in a neutral native div by default.
|
|
634
|
+
* @remarks
|
|
635
|
+
* ## Why
|
|
636
|
+
* Consumers can connect the resulting element with ordinary ARIA props where needed.
|
|
637
|
+
* ## Ownership and lifetime
|
|
638
|
+
* The Scope owns the description host/content and no external control.
|
|
639
|
+
* @since 1.0.0
|
|
640
|
+
* @category components
|
|
641
|
+
*/
|
|
642
|
+
export function Description(options, host) {
|
|
643
|
+
return Dom.renderHost()(options, host, descriptionProps(), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
|
|
644
|
+
}
|
|
645
|
+
function errorProps(options) {
|
|
646
|
+
return () => ({
|
|
647
|
+
id: fieldErrorId(options.state, options.name),
|
|
648
|
+
role: "alert",
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Renders the current field error as a native ARIA alert region.
|
|
653
|
+
* @remarks
|
|
654
|
+
* ## Why
|
|
655
|
+
* The same derived ID is placed on the error and in the control's
|
|
656
|
+
* `aria-describedby`, keeping validation messaging coherent.
|
|
657
|
+
* ## Ownership and lifetime
|
|
658
|
+
* The Scope owns the alert and state subscription; removing it does not remove form state.
|
|
659
|
+
* @since 1.0.0
|
|
660
|
+
* @category components
|
|
661
|
+
*/
|
|
662
|
+
export function Error(options, host) {
|
|
663
|
+
const content = RefSubject.map(options.state, (state) => state.errors[options.name] ?? "");
|
|
664
|
+
return Dom.renderHost()(options, host, errorProps(options), content, (props, value) => html `<div ...${props}>${value}</div>`);
|
|
665
|
+
}
|
|
666
|
+
function submitProps() {
|
|
667
|
+
return () => ({ type: "submit" });
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Renders a native `type=submit` button.
|
|
671
|
+
* @remarks
|
|
672
|
+
* ## Why
|
|
673
|
+
* Keyboard activation, form association, and accessibility stay browser-standard.
|
|
674
|
+
* ## Ownership and lifetime
|
|
675
|
+
* The Scope owns the button/content; the surrounding Form owns submission sequencing.
|
|
676
|
+
* @since 1.0.0
|
|
677
|
+
* @category components
|
|
678
|
+
*/
|
|
679
|
+
export function Submit(options, host) {
|
|
680
|
+
return Dom.renderHost()(options, host, submitProps(), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
681
|
+
}
|
|
682
|
+
function resetProps(options) {
|
|
683
|
+
return () => ({
|
|
684
|
+
type: "reset",
|
|
685
|
+
onclick: EventHandler.preventDefault(EventHandler.fromEffectOrEventHandler(reset(options.state))),
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Renders a native reset button that restores Typed form defaults.
|
|
690
|
+
* @remarks
|
|
691
|
+
* ## Why
|
|
692
|
+
* It prevents the browser's independent control mutation and resets the single
|
|
693
|
+
* RefSubject source of truth, clearing errors, metadata, and submitting state.
|
|
694
|
+
* ## Ownership and lifetime
|
|
695
|
+
* The Scope owns the click handler/content. The supplied form state remains independently owned.
|
|
696
|
+
* @since 1.0.0
|
|
697
|
+
* @category components
|
|
698
|
+
*/
|
|
699
|
+
export function Reset(options, host) {
|
|
700
|
+
return Dom.renderHost()(options, host, resetProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
701
|
+
}
|
|
702
|
+
function groupProps(options) {
|
|
703
|
+
return () => ({ role: "group", "aria-label": options.label });
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Renders an ARIA group with an optional accessible name.
|
|
707
|
+
* @remarks
|
|
708
|
+
* ## Why
|
|
709
|
+
* Related controls can expose their relationship without a framework-specific wrapper.
|
|
710
|
+
* ## Ownership and lifetime
|
|
711
|
+
* The Scope owns host/content; child controls retain their own DOM/state contracts.
|
|
712
|
+
* @since 1.0.0
|
|
713
|
+
* @category components
|
|
714
|
+
*/
|
|
715
|
+
export function Group(options, host) {
|
|
716
|
+
return Dom.renderHost()(options, host, groupProps(options), options.content, (props, content) => html `<div ...${props}>${content}</div>`);
|
|
717
|
+
}
|
|
718
|
+
function pushProps(options) {
|
|
719
|
+
return () => ({
|
|
720
|
+
type: "button",
|
|
721
|
+
onclick: pushValue(options.state, options.name, options.value),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Renders a button that appends one item to an array field.
|
|
726
|
+
* @remarks
|
|
727
|
+
* ## Why
|
|
728
|
+
* Array mutation is immutable, typed, and marks the field dirty/touched.
|
|
729
|
+
* ## Ownership and lifetime
|
|
730
|
+
* The Scope owns the button handler/content; state may outlive the button.
|
|
731
|
+
* @since 1.0.0
|
|
732
|
+
* @category components
|
|
733
|
+
*/
|
|
734
|
+
export function Push(options, host) {
|
|
735
|
+
return Dom.renderHost()(options, host, pushProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
736
|
+
}
|
|
737
|
+
function removeProps(options) {
|
|
738
|
+
return () => ({
|
|
739
|
+
type: "button",
|
|
740
|
+
onclick: removeValue(options.state, options.name, options.index),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Renders a button that removes one array item by index.
|
|
745
|
+
* @remarks
|
|
746
|
+
* ## Why
|
|
747
|
+
* Array mutation is immutable, typed, and marks the field dirty/touched.
|
|
748
|
+
* ## Ownership and lifetime
|
|
749
|
+
* The Scope owns the button handler/content; state may outlive the button.
|
|
750
|
+
* @since 1.0.0
|
|
751
|
+
* @category components
|
|
752
|
+
*/
|
|
753
|
+
export function Remove(options, host) {
|
|
754
|
+
return Dom.renderHost()(options, host, removeProps(options), options.content, (props, content) => html `<button ...${props}>${content}</button>`);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Sets one decoded field value and updates dirty/touched metadata.
|
|
758
|
+
* @remarks
|
|
759
|
+
* ## Why
|
|
760
|
+
* Programmatic updates use the same renderer-independent state transition as controls.
|
|
761
|
+
* ## Ownership and lifetime
|
|
762
|
+
* The returned Effect mutates only the supplied RefSubject when run.
|
|
763
|
+
* @since 1.0.0
|
|
764
|
+
* @category state
|
|
765
|
+
*/
|
|
766
|
+
export function setValue(state, key, value) {
|
|
767
|
+
return updateDecodedValue(state, key, value);
|
|
768
|
+
}
|
|
769
|
+
function updateDecodedValue(state, key, value) {
|
|
770
|
+
return RefSubject.update(state, (current) => ({
|
|
771
|
+
...current,
|
|
772
|
+
values: updateRecord(current.values, key, value),
|
|
773
|
+
meta: {
|
|
774
|
+
...current.meta,
|
|
775
|
+
[key]: { dirty: current.defaultValues[key] !== value, touched: true },
|
|
776
|
+
},
|
|
777
|
+
}));
|
|
778
|
+
}
|
|
779
|
+
function updateRecord(values, key, value) {
|
|
780
|
+
const updated = { ...values };
|
|
781
|
+
Reflect.set(updated, key, value);
|
|
782
|
+
return updated;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Restores default values and clears errors, interaction metadata, and submission state.
|
|
786
|
+
* @remarks
|
|
787
|
+
* ## Why
|
|
788
|
+
* Resetting the RefSubject, rather than only DOM controls, keeps every renderer
|
|
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`.
|
|
792
|
+
* @since 1.0.0
|
|
793
|
+
* @category state
|
|
794
|
+
*/
|
|
795
|
+
export function reset(state) {
|
|
796
|
+
return RefSubject.update(state, (current) => ({
|
|
797
|
+
...current,
|
|
798
|
+
values: current.defaultValues,
|
|
799
|
+
errors: {},
|
|
800
|
+
meta: {},
|
|
801
|
+
submitting: false,
|
|
802
|
+
}));
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Appends one item to an array field and marks it dirty and touched.
|
|
806
|
+
* @remarks
|
|
807
|
+
* ## Why
|
|
808
|
+
* The immutable state transition is usable in tests, commands, or any renderer.
|
|
809
|
+
* ## Ownership and lifetime
|
|
810
|
+
* The returned Effect performs one RefSubject update when run.
|
|
811
|
+
* @since 1.0.0
|
|
812
|
+
* @category state
|
|
813
|
+
*/
|
|
814
|
+
export function pushValue(state, name, value) {
|
|
815
|
+
return RefSubject.update(state, (current) => {
|
|
816
|
+
const previous = Reflect.get(current.values, name);
|
|
817
|
+
const values = Array.isArray(previous) ? [...previous, value] : [value];
|
|
818
|
+
return {
|
|
819
|
+
...current,
|
|
820
|
+
values: updateRecord(current.values, name, values),
|
|
821
|
+
meta: { ...current.meta, [name]: { dirty: true, touched: true } },
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Removes one item by index from an array field and marks it dirty and touched.
|
|
827
|
+
* @remarks
|
|
828
|
+
* ## Why
|
|
829
|
+
* The transition is explicit and renderer-independent; an out-of-range index leaves values unchanged.
|
|
830
|
+
* ## Ownership and lifetime
|
|
831
|
+
* The returned Effect performs one RefSubject update when run.
|
|
832
|
+
* @since 1.0.0
|
|
833
|
+
* @category state
|
|
834
|
+
*/
|
|
835
|
+
export function removeValue(state, name, index) {
|
|
836
|
+
return RefSubject.update(state, (current) => {
|
|
837
|
+
const previous = Reflect.get(current.values, name);
|
|
838
|
+
const values = Array.isArray(previous)
|
|
839
|
+
? previous.filter((_, currentIndex) => currentIndex !== index)
|
|
840
|
+
: [];
|
|
841
|
+
return {
|
|
842
|
+
...current,
|
|
843
|
+
values: updateRecord(current.values, name, values),
|
|
844
|
+
meta: { ...current.meta, [name]: { dirty: true, touched: true } },
|
|
845
|
+
};
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
function formProps(options) {
|
|
849
|
+
const setSubmitting = Effect.fn((submitting) => RefSubject.update(options.state, (current) => ({ ...current, submitting })));
|
|
850
|
+
return () => ({
|
|
851
|
+
ref: options.state,
|
|
852
|
+
onsubmit: EventHandler.make(Effect.fn((event) => Effect.andThen(setSubmitting(true), Effect.matchEffect(validate(options.state), {
|
|
853
|
+
onFailure: Effect.fn(() => Effect.void),
|
|
854
|
+
onSuccess: Effect.fn((values) => {
|
|
855
|
+
const result = options.onValidSubmit?.(values, event);
|
|
856
|
+
return Effect.isEffect(result) ? result : Effect.void;
|
|
857
|
+
}),
|
|
858
|
+
})).pipe(Effect.ensuring(setSubmitting(false).pipe(Effect.catch(Effect.fn(() => Effect.void)))))), { preventDefault: true }),
|
|
859
|
+
onreset: EventHandler.preventDefault(EventHandler.fromEffectOrEventHandler(reset(options.state))),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Renders a native form and provides its state to schema-bound descendants.
|
|
864
|
+
* @remarks
|
|
865
|
+
* ## Why
|
|
866
|
+
* Native submission is intercepted once, whole-form Schema validation runs,
|
|
867
|
+
* then `onValidSubmit` receives decoded values. Reset updates the RefSubject so
|
|
868
|
+
* all renderers stay coherent.
|
|
869
|
+
* ## Ownership and lifetime
|
|
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.
|
|
873
|
+
* @example
|
|
874
|
+
* ```ts
|
|
875
|
+
* import { Form, Submit, TextInput, makeState } from "@typed/ui/Form"
|
|
876
|
+
* import { Effect, Schema } from "effect"
|
|
877
|
+
* import { html } from "@typed/template"
|
|
878
|
+
*
|
|
879
|
+
* const codec = Schema.Struct({ email: Schema.String })
|
|
880
|
+
* const view = Effect.gen(function* () {
|
|
881
|
+
* const state = yield* makeState(codec, { id: "signup", values: { email: "" } })
|
|
882
|
+
* return Form({
|
|
883
|
+
* state,
|
|
884
|
+
* content: html`${TextInput({ state, name: "email" })}${Submit({ content: "Join" })}`,
|
|
885
|
+
* onValidSubmit: (values) => Effect.log(`Submitting ${values.email}`)
|
|
886
|
+
* })
|
|
887
|
+
* })
|
|
888
|
+
* ```
|
|
889
|
+
* @since 1.0.0
|
|
890
|
+
* @category components
|
|
891
|
+
*/
|
|
892
|
+
export function Form(options, host) {
|
|
893
|
+
const rendered = Dom.renderHost()(options, host, formProps(options), options.content, (props, content) => html `<form ...${props}>${content}</form>`);
|
|
894
|
+
return FxApi.provideService(rendered, CurrentForm, {
|
|
895
|
+
state: options.state,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Creates a schema-bound form component family from one Struct codec.
|
|
900
|
+
* @remarks
|
|
901
|
+
* ## Why
|
|
902
|
+
* Defining the schema once removes repeated state/codec arguments and makes
|
|
903
|
+
* incompatible field/component combinations compile-time errors.
|
|
904
|
+
* ## Ownership and lifetime
|
|
905
|
+
* Factory creation is pure and retains the codec. `state` requires Scope and
|
|
906
|
+
* returns a hydrated RefSubject. `Root` provides that state through Effect
|
|
907
|
+
* context only for its render lifetime; controls borrow it.
|
|
908
|
+
* @example
|
|
909
|
+
* ```ts
|
|
910
|
+
* import { make } from "@typed/ui/Form"
|
|
911
|
+
* import { Effect, Schema } from "effect"
|
|
912
|
+
* import { html } from "@typed/template"
|
|
913
|
+
*
|
|
914
|
+
* const Signup = make(Schema.Struct({ email: Schema.String, accepted: Schema.Boolean }))
|
|
915
|
+
* const view = Effect.gen(function* () {
|
|
916
|
+
* const form = yield* Signup.state({ email: "", accepted: false }, { id: "signup" })
|
|
917
|
+
* return Signup.Root({
|
|
918
|
+
* form,
|
|
919
|
+
* content: html`${Signup.EmailInput({ name: "email" })}${Signup.Checkbox({ name: "accepted" })}`
|
|
920
|
+
* })
|
|
921
|
+
* })
|
|
922
|
+
* ```
|
|
923
|
+
* @since 1.0.0
|
|
924
|
+
* @category constructors
|
|
925
|
+
*/
|
|
926
|
+
export function make(codec) {
|
|
927
|
+
const state = (values, options = {}) => makeState(codec, { ...options, values });
|
|
928
|
+
const Root = (options, host) => {
|
|
929
|
+
const { form, ...rootOptions } = options;
|
|
930
|
+
return Form({ ...rootOptions, state: form }, host);
|
|
931
|
+
};
|
|
932
|
+
const BoundCheckbox = (options, host) => withCurrentForm()((state) => {
|
|
933
|
+
const inputOptions = { ...options, state };
|
|
934
|
+
return Checkbox(inputOptions, host);
|
|
935
|
+
});
|
|
936
|
+
const BoundSelect = (options, host) => withCurrentForm()((state) => {
|
|
937
|
+
const inputOptions = { ...options, state };
|
|
938
|
+
return Select(inputOptions, host);
|
|
939
|
+
});
|
|
940
|
+
const BoundError = (options, host) => withCurrentForm()((state) => {
|
|
941
|
+
const inputOptions = { ...options, state };
|
|
942
|
+
return Error(inputOptions, host);
|
|
943
|
+
});
|
|
944
|
+
const BoundReset = (options, host) => withCurrentForm()((state) => {
|
|
945
|
+
const inputOptions = { ...options, state };
|
|
946
|
+
return Reset(inputOptions, host);
|
|
947
|
+
});
|
|
948
|
+
const BoundPush = (options, host) => withCurrentForm()((state) => {
|
|
949
|
+
const inputOptions = { ...options, state };
|
|
950
|
+
return Push(inputOptions, host);
|
|
951
|
+
});
|
|
952
|
+
const BoundRemove = (options, host) => withCurrentForm()((state) => {
|
|
953
|
+
const inputOptions = { ...options, state };
|
|
954
|
+
return Remove(inputOptions, host);
|
|
955
|
+
});
|
|
956
|
+
return {
|
|
957
|
+
codec,
|
|
958
|
+
state,
|
|
959
|
+
Root,
|
|
960
|
+
TextInput: makeSchemaBoundInput(codec, "text"),
|
|
961
|
+
SearchInput: makeSchemaBoundInput(codec, "search"),
|
|
962
|
+
EmailInput: makeSchemaBoundInput(codec, "email"),
|
|
963
|
+
UrlInput: makeSchemaBoundInput(codec, "url"),
|
|
964
|
+
TelInput: makeSchemaBoundInput(codec, "tel"),
|
|
965
|
+
PasswordInput: makeSchemaBoundInput(codec, "password"),
|
|
966
|
+
HiddenInput: makeSchemaBoundInput(codec, "hidden"),
|
|
967
|
+
ColorInput: makeSchemaBoundInput(codec, "color"),
|
|
968
|
+
TimeInput: makeSchemaBoundInput(codec, "time"),
|
|
969
|
+
DateTimeLocalInput: makeSchemaBoundInput(codec, "datetime-local"),
|
|
970
|
+
MonthInput: makeSchemaBoundInput(codec, "month"),
|
|
971
|
+
WeekInput: makeSchemaBoundInput(codec, "week"),
|
|
972
|
+
NumberInput: makeSchemaBoundInput(codec, "number"),
|
|
973
|
+
RangeInput: makeSchemaBoundInput(codec, "range"),
|
|
974
|
+
DateInput: makeSchemaBoundInput(codec, "date"),
|
|
975
|
+
MaskedInput: makeSchemaBoundInput(codec, "text"),
|
|
976
|
+
Checkbox: BoundCheckbox,
|
|
977
|
+
Select: BoundSelect,
|
|
978
|
+
Error: BoundError,
|
|
979
|
+
Reset: BoundReset,
|
|
980
|
+
Push: BoundPush,
|
|
981
|
+
Remove: BoundRemove,
|
|
982
|
+
Label,
|
|
983
|
+
Description,
|
|
984
|
+
Submit,
|
|
985
|
+
Group,
|
|
986
|
+
};
|
|
987
|
+
}
|