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