vanilla-jui 1.4.8 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/index.d.ts +402 -977
- package/dist/index.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/components/accordion.ts +467 -349
- package/src/components/flow.ts +779 -1133
- package/src/components/form.ts +666 -509
- package/src/components/menu.ts +199 -326
- package/src/components/modal.ts +510 -747
- package/src/components/offcanvas.ts +281 -361
- package/src/components/pagination.ts +207 -374
- package/src/components/swiper.ts +688 -1168
- package/src/components/tabs.ts +468 -788
- package/src/components/toc.ts +191 -244
- package/src/core/component.ts +332 -0
- package/src/core/motion.ts +298 -0
- package/src/core/presence.ts +233 -0
- package/src/core/scheduler.ts +91 -0
- package/src/core/view.ts +31 -0
- package/src/css/accordion.css +94 -3
- package/src/css/content.css +18 -24
- package/src/css/drop.css +4 -1
- package/src/css/form.css +8 -0
- package/src/css/menu.css +10 -5
- package/src/css/modal.css +2 -1
- package/src/css/offcanvas.css +5 -17
- package/src/css/swiper.css +2 -1
- package/src/css/tabs.css +8 -17
- package/src/css/themes/default/accordion.css +0 -48
- package/src/css/themes/default/swiper.css +1 -1
- package/src/css/themes/default/tabs.css +4 -6
- package/src/css/toast.css +1 -17
- package/src/css/tooltip.css +2 -1
- package/src/index.ts +8 -3
- package/src/locales/index.ts +3 -0
- package/src/primitives/drop.ts +253 -262
- package/src/primitives/loading.ts +22 -4
- package/src/primitives/parabola.ts +123 -164
- package/src/primitives/sticky.ts +297 -0
- package/src/primitives/theme.ts +164 -222
- package/src/primitives/toast.ts +309 -284
- package/src/primitives/tooltip.ts +65 -66
- package/src/utilities/browser.ts +38 -0
- package/src/utilities/dom.ts +0 -54
- package/src/utilities/refs.ts +61 -0
- package/src/utilities/state.ts +73 -0
- package/src/utilities/timer.ts +11 -31
- package/src/validation/validator.ts +289 -389
- package/src/components/sticky.ts +0 -297
- package/src/core/Component.ts +0 -377
- package/src/utilities/scheduler.ts +0 -156
package/src/components/form.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
|
+
For,
|
|
2
3
|
createDeepStore,
|
|
3
|
-
|
|
4
|
+
createEffect,
|
|
5
|
+
createMemo,
|
|
6
|
+
createSignal,
|
|
4
7
|
flushSync,
|
|
5
8
|
jsx,
|
|
6
|
-
onCleanup,
|
|
7
|
-
render,
|
|
8
9
|
} from 'vanilla-signal';
|
|
9
10
|
|
|
10
|
-
import
|
|
11
|
+
import {
|
|
12
|
+
type FunctionalComponent,
|
|
13
|
+
defineComponent,
|
|
14
|
+
} from '../core/component.ts';
|
|
11
15
|
import { joinClasses } from '../utilities/class-name.ts';
|
|
12
16
|
import {
|
|
13
17
|
type RenderableContent,
|
|
14
18
|
all,
|
|
15
|
-
normalizeRenderableContentNodes,
|
|
16
19
|
} from '../utilities/dom.ts';
|
|
17
20
|
import { randomId } from '../utilities/id.ts';
|
|
18
21
|
import { isPlainObject } from '../utilities/object.ts';
|
|
@@ -22,22 +25,23 @@ import {
|
|
|
22
25
|
validateParam,
|
|
23
26
|
} from '../utilities/types.ts';
|
|
24
27
|
import { createValidator } from '../validation/validator.ts';
|
|
28
|
+
import { createLoading } from '../primitives/loading.ts';
|
|
25
29
|
|
|
26
30
|
type FormValue = string | number | boolean;
|
|
27
|
-
type
|
|
28
|
-
type FormControlElement =
|
|
29
|
-
| HTMLInputElement
|
|
30
|
-
| HTMLSelectElement
|
|
31
|
-
| HTMLTextAreaElement;
|
|
31
|
+
export type FieldOption = FormValue | FormOption;
|
|
32
32
|
type FormStyle = string | Partial<CSSStyleDeclaration> | null;
|
|
33
33
|
export type FormDataValue = FormDataEntryValue | FormDataEntryValue[];
|
|
34
34
|
export type FormDataRecord = Record<string, FormDataValue>;
|
|
35
|
+
export type FormItemType = FormControlType;
|
|
35
36
|
type FormControlType =
|
|
36
37
|
| 'checkbox'
|
|
37
38
|
| 'custom'
|
|
39
|
+
| 'email'
|
|
40
|
+
| 'password'
|
|
38
41
|
| 'radio'
|
|
39
42
|
| 'select'
|
|
40
43
|
| 'switch'
|
|
44
|
+
| 'text'
|
|
41
45
|
| 'textarea'
|
|
42
46
|
| (string & {});
|
|
43
47
|
|
|
@@ -80,12 +84,24 @@ export interface FormOption {
|
|
|
80
84
|
disabled?: boolean;
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
export type FormItemNext = (
|
|
88
|
+
current: FormItem,
|
|
89
|
+
acients: FormItem[]
|
|
90
|
+
) => FormItem | null;
|
|
91
|
+
|
|
92
|
+
export interface FormItem<TPayload = FormField> {
|
|
93
|
+
id?: string;
|
|
94
|
+
type: FormItemType;
|
|
95
|
+
payload: TPayload;
|
|
96
|
+
next?: FormItemNext | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
export interface FormField {
|
|
100
|
+
[key: string]: unknown;
|
|
84
101
|
id?: string;
|
|
85
102
|
label?: RenderableContent<Form> | false;
|
|
86
103
|
name?: string;
|
|
87
|
-
|
|
88
|
-
options?: readonly FormOptionInput[];
|
|
104
|
+
options?: readonly FieldOption[];
|
|
89
105
|
value?: FormDataEntryValue | boolean | readonly FormDataEntryValue[];
|
|
90
106
|
checked?: boolean;
|
|
91
107
|
required?: boolean;
|
|
@@ -125,7 +141,7 @@ export interface FormProps extends Record<string, unknown> {
|
|
|
125
141
|
vertical?: boolean;
|
|
126
142
|
itemVertical?: boolean;
|
|
127
143
|
style?: FormStyle;
|
|
128
|
-
fields?: readonly FormField[];
|
|
144
|
+
fields?: readonly FormItem<FormField>[];
|
|
129
145
|
buttons?: boolean | readonly FormButton[];
|
|
130
146
|
className?: FormClassNameConfig;
|
|
131
147
|
validator?: FormValidatorConfig;
|
|
@@ -140,7 +156,7 @@ interface ResolvedFormProps extends Record<string, unknown> {
|
|
|
140
156
|
vertical: boolean;
|
|
141
157
|
itemVertical: boolean;
|
|
142
158
|
style: FormStyle;
|
|
143
|
-
fields: FormField[];
|
|
159
|
+
fields: FormItem<FormField>[];
|
|
144
160
|
buttons: FormButton[];
|
|
145
161
|
className: FormClassNames;
|
|
146
162
|
validator: FormValidatorConfig;
|
|
@@ -153,18 +169,13 @@ interface FormState extends ResolvedFormProps {
|
|
|
153
169
|
data: FormDataRecord | null;
|
|
154
170
|
}
|
|
155
171
|
|
|
156
|
-
interface FormDOM extends ComponentDOM {
|
|
157
|
-
root: HTMLFormElement | null;
|
|
158
|
-
fields: Map<string, FormControlElement>;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
172
|
interface FormCache {
|
|
162
173
|
initial: ResolvedFormProps;
|
|
163
174
|
fieldIds: Map<string | number, string>;
|
|
164
175
|
}
|
|
165
176
|
|
|
166
177
|
interface ValidatorInstance {
|
|
167
|
-
|
|
178
|
+
readonly element: Element | null;
|
|
168
179
|
props: (FormValidatorConfig & { onSubmit: null }) | null;
|
|
169
180
|
validate: () => boolean;
|
|
170
181
|
reset: () => void;
|
|
@@ -175,6 +186,7 @@ interface FormControlContext {
|
|
|
175
186
|
form: Form;
|
|
176
187
|
field: FormField;
|
|
177
188
|
index: number;
|
|
189
|
+
item: FormItem<FormField>;
|
|
178
190
|
}
|
|
179
191
|
|
|
180
192
|
const DEFAULT_CLASS_NAMES: FormClassNames = {
|
|
@@ -211,6 +223,9 @@ const DEFAULT_BUTTONS: FormButton[] = [
|
|
|
211
223
|
{ type: 'reset', text: 'Reset', theme: 'ghost', action: 'reset' },
|
|
212
224
|
];
|
|
213
225
|
|
|
226
|
+
// Guard against malformed dynamic chains that never terminate.
|
|
227
|
+
const FORM_ITEM_LIMIT = 1000;
|
|
228
|
+
|
|
214
229
|
const FORM_PROPS_SCHEMA = {
|
|
215
230
|
id: {
|
|
216
231
|
default: null,
|
|
@@ -247,20 +262,35 @@ const FORM_PROPS_SCHEMA = {
|
|
|
247
262
|
} satisfies ResolveSchema<FormProps>;
|
|
248
263
|
|
|
249
264
|
function cloneOptions(
|
|
250
|
-
options: readonly
|
|
251
|
-
):
|
|
265
|
+
options: readonly FieldOption[] | undefined
|
|
266
|
+
): FieldOption[] {
|
|
252
267
|
if (!Array.isArray(options)) return [];
|
|
253
268
|
return options.map((option) =>
|
|
254
269
|
option && typeof option === 'object' ? { ...option } : option
|
|
255
270
|
);
|
|
256
271
|
}
|
|
257
272
|
|
|
258
|
-
function
|
|
259
|
-
|
|
260
|
-
return fields.map((field) => ({
|
|
273
|
+
function cloneField(field: FormField): FormField {
|
|
274
|
+
return {
|
|
261
275
|
...field,
|
|
262
276
|
options: cloneOptions(field.options),
|
|
263
|
-
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function cloneFormItem(item: FormItem<FormField>): FormItem<FormField> {
|
|
281
|
+
return {
|
|
282
|
+
...(item.id ? { id: item.id } : {}),
|
|
283
|
+
type: item.type,
|
|
284
|
+
payload: cloneField(item.payload),
|
|
285
|
+
next: item.next || null,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function cloneFields(
|
|
290
|
+
fields: readonly FormItem<FormField>[] | undefined
|
|
291
|
+
): FormItem<FormField>[] {
|
|
292
|
+
if (!Array.isArray(fields)) return [];
|
|
293
|
+
return fields.map((item) => cloneFormItem(item));
|
|
264
294
|
}
|
|
265
295
|
|
|
266
296
|
function cloneButtons(
|
|
@@ -270,7 +300,43 @@ function cloneButtons(
|
|
|
270
300
|
return buttons.map((button) => ({ ...button }));
|
|
271
301
|
}
|
|
272
302
|
|
|
273
|
-
function
|
|
303
|
+
function flattenFormItems(
|
|
304
|
+
rootsInput: readonly FormItem<FormField>[],
|
|
305
|
+
resolveItem: (item: FormItem<FormField>) => FormItem<FormField>
|
|
306
|
+
): FormItem<FormField>[] {
|
|
307
|
+
const result: FormItem<FormField>[] = [];
|
|
308
|
+
const roots = Array.isArray(rootsInput) ? rootsInput : [];
|
|
309
|
+
const seen = new WeakSet<FormItem<FormField>>();
|
|
310
|
+
const defaultNext = (
|
|
311
|
+
current: FormItem<FormField>
|
|
312
|
+
): FormItem<FormField> | null => {
|
|
313
|
+
const index = roots.indexOf(current);
|
|
314
|
+
return index >= 0 ? (roots[index + 1] || null) : null;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
for (const root of roots) {
|
|
318
|
+
if (seen.has(root)) continue;
|
|
319
|
+
let current: FormItem<FormField> | null = root;
|
|
320
|
+
const acients: FormItem<FormField>[] = [];
|
|
321
|
+
let guard = 0;
|
|
322
|
+
|
|
323
|
+
while (current && guard < FORM_ITEM_LIMIT) {
|
|
324
|
+
guard += 1;
|
|
325
|
+
if (seen.has(current)) break;
|
|
326
|
+
seen.add(current);
|
|
327
|
+
result.push(current);
|
|
328
|
+
acients.push(current);
|
|
329
|
+
const next = current.next
|
|
330
|
+
? current.next(current, acients)
|
|
331
|
+
: defaultNext(current);
|
|
332
|
+
current = next ? resolveItem(next) : null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function normalizeOption(option: FieldOption): FormOption {
|
|
274
340
|
if (option && typeof option === 'object') return option;
|
|
275
341
|
return { value: option, text: String(option) };
|
|
276
342
|
}
|
|
@@ -337,13 +403,14 @@ function cloneValidator(validator: unknown): FormValidatorConfig {
|
|
|
337
403
|
|
|
338
404
|
function normalizeProps(input: FormProps): ResolvedFormProps {
|
|
339
405
|
const props = resolveProps(input, FORM_PROPS_SCHEMA, 'Form.props');
|
|
406
|
+
const fields = cloneFields(props.fields as readonly FormItem<FormField>[]);
|
|
340
407
|
return {
|
|
341
408
|
...props,
|
|
342
409
|
id: String(props.id),
|
|
343
410
|
vertical: Boolean(props.vertical),
|
|
344
411
|
itemVertical: Boolean(props.itemVertical),
|
|
345
412
|
style: props.style as FormStyle,
|
|
346
|
-
fields
|
|
413
|
+
fields,
|
|
347
414
|
buttons: cloneButtons(props.buttons as readonly FormButton[]),
|
|
348
415
|
className: resolveClassNames(props.className),
|
|
349
416
|
validator: cloneValidator(props.validator),
|
|
@@ -352,230 +419,271 @@ function normalizeProps(input: FormProps): ResolvedFormProps {
|
|
|
352
419
|
};
|
|
353
420
|
}
|
|
354
421
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
this.dom.fields = new Map();
|
|
365
|
-
this.validator = null;
|
|
366
|
-
this.cleanup.view = null;
|
|
367
|
-
|
|
368
|
-
this.cache = {
|
|
369
|
-
initial: this.cloneProps(props),
|
|
370
|
-
fieldIds: new Map(),
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
this.state = createDeepStore({
|
|
374
|
-
...this.cloneProps(props),
|
|
375
|
-
submitting: false,
|
|
376
|
-
data: null,
|
|
377
|
-
});
|
|
378
|
-
}
|
|
422
|
+
interface FormActions {
|
|
423
|
+
validate(): boolean;
|
|
424
|
+
reset(): Form;
|
|
425
|
+
collectData(): FormDataRecord;
|
|
426
|
+
requestSubmit(): Form;
|
|
427
|
+
setFields(fields: readonly FormItem<FormField>[]): Form;
|
|
428
|
+
resetFields(): Form;
|
|
429
|
+
}
|
|
379
430
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
431
|
+
export type Form = FunctionalComponent<
|
|
432
|
+
ResolvedFormProps,
|
|
433
|
+
FormState,
|
|
434
|
+
HTMLFormElement,
|
|
435
|
+
FormActions
|
|
436
|
+
>;
|
|
383
437
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
438
|
+
function cloneResolvedProps(props: ResolvedFormProps): ResolvedFormProps {
|
|
439
|
+
return {
|
|
440
|
+
...props,
|
|
441
|
+
fields: cloneFields(props.fields),
|
|
442
|
+
buttons: cloneButtons(props.buttons),
|
|
443
|
+
className: resolveClassNames(props.className),
|
|
444
|
+
validator: cloneValidator(props.validator),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
387
447
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const host = document.createElement('div');
|
|
395
|
-
this.cleanup.view = createRoot((dispose) => {
|
|
396
|
-
const viewDispose = render(() => this.view(), host);
|
|
397
|
-
onCleanup(viewDispose);
|
|
398
|
-
return dispose;
|
|
399
|
-
});
|
|
448
|
+
function autoComplete(type: string): string {
|
|
449
|
+
if (type === 'password') return 'current-password';
|
|
450
|
+
if (type === 'email') return 'email';
|
|
451
|
+
return 'on';
|
|
452
|
+
}
|
|
400
453
|
|
|
401
|
-
|
|
454
|
+
function isSelected(
|
|
455
|
+
value: FormField['value'] | undefined,
|
|
456
|
+
optionValue: FormOption['value']
|
|
457
|
+
): boolean {
|
|
458
|
+
if (Array.isArray(value)) {
|
|
459
|
+
return value
|
|
460
|
+
.map((item) => stringifyFormValue(item))
|
|
461
|
+
.includes(stringifyFormValue(optionValue));
|
|
402
462
|
}
|
|
463
|
+
return value == optionValue;
|
|
464
|
+
}
|
|
403
465
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
),
|
|
415
|
-
id: () => this.state.id,
|
|
416
|
-
style: () => this.state.style,
|
|
417
|
-
'data-form': 'root',
|
|
418
|
-
'data-form-layout': () =>
|
|
419
|
-
this.state.vertical ? 'vertical' : 'horizontal',
|
|
420
|
-
'data-form-item-layout': () =>
|
|
421
|
-
this.state.itemVertical ? 'vertical' : 'horizontal',
|
|
422
|
-
ref: (element: HTMLFormElement) => {
|
|
423
|
-
const changed = this.root !== element;
|
|
424
|
-
this.root = element;
|
|
425
|
-
setElementStyle(element, this.state.style);
|
|
426
|
-
if (changed || !this.validator) this.syncValidator();
|
|
427
|
-
},
|
|
428
|
-
onSubmit: (event: Event) => this.handleSubmit(event),
|
|
429
|
-
onReset: (event: Event) => this.handleReset(event),
|
|
430
|
-
children: () => [
|
|
431
|
-
...this.state.fields.map((field, index) =>
|
|
432
|
-
this.fieldView(field, index)
|
|
433
|
-
),
|
|
434
|
-
this.buttonsView(),
|
|
435
|
-
],
|
|
436
|
-
});
|
|
466
|
+
function isChecked(
|
|
467
|
+
value: FormField['value'] | undefined,
|
|
468
|
+
optionValue: FormOption['value'],
|
|
469
|
+
checked: boolean | undefined
|
|
470
|
+
): boolean {
|
|
471
|
+
if (checked !== undefined) return checked;
|
|
472
|
+
if (Array.isArray(value)) {
|
|
473
|
+
return value
|
|
474
|
+
.map((item) => stringifyFormValue(item))
|
|
475
|
+
.includes(stringifyFormValue(optionValue));
|
|
437
476
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
return jsx('div', {
|
|
445
|
-
className: className.item,
|
|
446
|
-
'data-form-item': field.name || String(index),
|
|
447
|
-
style: { display: hidden ? 'none' : '' },
|
|
448
|
-
children: [
|
|
449
|
-
this.labelView(field, id),
|
|
450
|
-
jsx('div', {
|
|
451
|
-
className: className.control,
|
|
452
|
-
'data-form-control': field.name || String(index),
|
|
453
|
-
children: [
|
|
454
|
-
this.controlView(field, id, index),
|
|
455
|
-
field.help
|
|
456
|
-
? jsx('div', {
|
|
457
|
-
className: 'help-block',
|
|
458
|
-
'data-form-help': field.name || String(index),
|
|
459
|
-
children: field.help,
|
|
460
|
-
})
|
|
461
|
-
: null,
|
|
462
|
-
],
|
|
463
|
-
}),
|
|
464
|
-
],
|
|
465
|
-
});
|
|
477
|
+
if (
|
|
478
|
+
typeof value !== 'string' &&
|
|
479
|
+
typeof value !== 'boolean' &&
|
|
480
|
+
!(value instanceof File)
|
|
481
|
+
) {
|
|
482
|
+
return false;
|
|
466
483
|
}
|
|
484
|
+
return (
|
|
485
|
+
value === true ||
|
|
486
|
+
stringifyFormValue(value) === stringifyFormValue(optionValue)
|
|
487
|
+
);
|
|
488
|
+
}
|
|
467
489
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
490
|
+
function radioNodeListItems(value: RadioNodeList | Element): Element[] {
|
|
491
|
+
if (value instanceof RadioNodeList) return Array.from(value);
|
|
492
|
+
return [value];
|
|
493
|
+
}
|
|
471
494
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
495
|
+
export function createForm(input: FormProps = {}): Form {
|
|
496
|
+
const props = normalizeProps(input);
|
|
497
|
+
const initial = cloneResolvedProps(props);
|
|
498
|
+
const state = createDeepStore({
|
|
499
|
+
...cloneResolvedProps(props),
|
|
500
|
+
submitting: false,
|
|
501
|
+
data: null,
|
|
502
|
+
}) as FormState;
|
|
503
|
+
const cache: FormCache = { initial, fieldIds: new Map() };
|
|
504
|
+
const fieldKeys = new WeakMap<object, string>();
|
|
505
|
+
const [chainVersion, setChainVersion] = createSignal(0);
|
|
506
|
+
let runtimeItems = new WeakMap<FormItem<FormField>, FormItem<FormField>>();
|
|
507
|
+
let validator: ValidatorInstance | null = null;
|
|
508
|
+
let form!: Form;
|
|
509
|
+
const resolveRuntimeItem = (
|
|
510
|
+
item: FormItem<FormField>
|
|
511
|
+
): FormItem<FormField> => {
|
|
512
|
+
if (state.fields.includes(item)) return item;
|
|
513
|
+
const cached = runtimeItems.get(item);
|
|
514
|
+
if (cached) return cached;
|
|
515
|
+
const runtimeItem = createDeepStore(cloneFormItem(item)) as FormItem<FormField>;
|
|
516
|
+
runtimeItems.set(item, runtimeItem);
|
|
517
|
+
return runtimeItem;
|
|
518
|
+
};
|
|
519
|
+
const renderedItems = createMemo(() => {
|
|
520
|
+
chainVersion();
|
|
521
|
+
return flattenFormItems(state.fields, resolveRuntimeItem);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const fieldKey = (field: FormField): string => {
|
|
525
|
+
if (field.id) return `id:${field.id}`;
|
|
526
|
+
if (field.name) return `name:${field.name}`;
|
|
527
|
+
if (!fieldKeys.has(field)) fieldKeys.set(field, randomId());
|
|
528
|
+
return fieldKeys.get(field) as string;
|
|
529
|
+
};
|
|
530
|
+
const itemKey = (item: FormItem, index: number): string => {
|
|
531
|
+
if (item.id) return `item:${item.id}`;
|
|
532
|
+
const field = item.payload as FormField;
|
|
533
|
+
if (field && typeof field === 'object') return `${fieldKey(field)}:${item.type}`;
|
|
534
|
+
return `item:${item.type}:${index}`;
|
|
535
|
+
};
|
|
536
|
+
const resolveFieldId = (field: FormField, index: number): string => {
|
|
537
|
+
if (field.id) return field.id;
|
|
538
|
+
const key = field.name || index;
|
|
539
|
+
if (!cache.fieldIds.has(key)) {
|
|
540
|
+
cache.fieldIds.set(key, `${state.id}_field_${index}`);
|
|
541
|
+
}
|
|
542
|
+
return cache.fieldIds.get(key) as string;
|
|
543
|
+
};
|
|
544
|
+
const syncValidator = (): void => {
|
|
545
|
+
const element = form.element;
|
|
546
|
+
if (!element) return;
|
|
547
|
+
const options = { ...state.validator, onSubmit: null };
|
|
548
|
+
if (validator?.element === element) {
|
|
549
|
+
validator.props = options;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
validator?.destroy();
|
|
553
|
+
validator = createValidator(element, options, false) as ValidatorInstance;
|
|
554
|
+
};
|
|
555
|
+
const validate = (): boolean => validator?.validate() ?? true;
|
|
556
|
+
const resetValidationState = (): void => {
|
|
557
|
+
const element = form.element;
|
|
558
|
+
if (!element) return;
|
|
559
|
+
for (const control of Array.from(element.elements)) {
|
|
560
|
+
control.classList.remove('is-valid', 'is-invalid');
|
|
561
|
+
}
|
|
562
|
+
for (const help of all<HTMLElement>(
|
|
563
|
+
'[data-form-help], [data-validator-help]',
|
|
564
|
+
element
|
|
565
|
+
)) {
|
|
566
|
+
if (help.dataset.validatorHelp || help.classList.contains('is-invalid')) {
|
|
567
|
+
help.remove();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const collectData = (): FormDataRecord => {
|
|
572
|
+
const data: FormDataRecord = {};
|
|
573
|
+
if (!form.element) return data;
|
|
574
|
+
for (const [key, value] of new FormData(form.element).entries()) {
|
|
575
|
+
if (Object.hasOwn(data, key)) {
|
|
576
|
+
data[key] = Array.isArray(data[key])
|
|
577
|
+
? [...data[key], value]
|
|
578
|
+
: [data[key], value];
|
|
579
|
+
} else data[key] = value;
|
|
580
|
+
}
|
|
581
|
+
return data;
|
|
582
|
+
};
|
|
583
|
+
const handleSubmit = async (event: Event): Promise<void> => {
|
|
584
|
+
event.preventDefault();
|
|
585
|
+
if (state.submitting || !validate()) return;
|
|
586
|
+
const data = collectData();
|
|
587
|
+
flushSync(() => {
|
|
588
|
+
state.submitting = true;
|
|
589
|
+
state.data = data;
|
|
482
590
|
});
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
): FormControlElement | HTMLElement | Node[] | null {
|
|
490
|
-
switch (field.type) {
|
|
491
|
-
case 'textarea':
|
|
492
|
-
return this.textareaView(field, id);
|
|
493
|
-
case 'select':
|
|
494
|
-
return this.selectView(field, id);
|
|
495
|
-
case 'radio':
|
|
496
|
-
return this.choiceGroupView(field, id, 'radio');
|
|
497
|
-
case 'checkbox':
|
|
498
|
-
return Array.isArray(field.options)
|
|
499
|
-
? this.choiceGroupView(field, id, 'checkbox')
|
|
500
|
-
: this.inputView(field, id);
|
|
501
|
-
case 'switch':
|
|
502
|
-
return this.switchView(field, id);
|
|
503
|
-
case 'custom':
|
|
504
|
-
return normalizeRenderableContentNodes(field.content, {
|
|
505
|
-
form: this,
|
|
506
|
-
field,
|
|
507
|
-
index,
|
|
591
|
+
try {
|
|
592
|
+
await Promise.resolve(state.onSubmit?.(data, form));
|
|
593
|
+
} finally {
|
|
594
|
+
if (!form.runtime.destroyed) {
|
|
595
|
+
flushSync(() => {
|
|
596
|
+
state.submitting = false;
|
|
508
597
|
});
|
|
509
|
-
|
|
510
|
-
return this.inputView(field, id);
|
|
598
|
+
}
|
|
511
599
|
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
inputView(field: FormField, id: string): HTMLInputElement {
|
|
515
|
-
const type = field.type || 'text';
|
|
516
|
-
const props = this.controlProps(field, id, {
|
|
517
|
-
type,
|
|
518
|
-
className: field.className || this.state.className.input,
|
|
519
|
-
autocomplete: field.autocomplete || this.autoComplete(type),
|
|
520
|
-
value: field.value ?? '',
|
|
521
|
-
});
|
|
600
|
+
};
|
|
522
601
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
602
|
+
const fieldControls = (field: FormField): Element[] => {
|
|
603
|
+
if (!field.name || !form.element) return [];
|
|
604
|
+
const element = form.element.elements.namedItem(field.name);
|
|
605
|
+
if (!element) return [];
|
|
606
|
+
return radioNodeListItems(element).filter((item) => item instanceof Element);
|
|
607
|
+
};
|
|
608
|
+
const syncFieldValue = (
|
|
609
|
+
field: FormField,
|
|
610
|
+
control:
|
|
611
|
+
| HTMLInputElement
|
|
612
|
+
| HTMLSelectElement
|
|
613
|
+
| HTMLTextAreaElement,
|
|
614
|
+
type: FormControlType | 'checkbox' | 'radio' = 'text'
|
|
615
|
+
): void => {
|
|
616
|
+
flushSync(() => {
|
|
617
|
+
if (control instanceof HTMLSelectElement) {
|
|
618
|
+
field.value = control.multiple
|
|
619
|
+
? Array.from(control.selectedOptions).map((option) => option.value)
|
|
620
|
+
: control.value;
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (control instanceof HTMLTextAreaElement) {
|
|
624
|
+
field.value = control.value;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (type === 'switch') {
|
|
628
|
+
field.checked = control.checked;
|
|
629
|
+
field.value = control.checked ? control.value : '';
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (type === 'radio') {
|
|
633
|
+
if (control.checked) field.value = control.value;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (type === 'checkbox') {
|
|
637
|
+
if (Array.isArray(field.options)) {
|
|
638
|
+
field.value = fieldControls(field)
|
|
639
|
+
.filter(
|
|
640
|
+
(item): item is HTMLInputElement =>
|
|
641
|
+
item instanceof HTMLInputElement && item.checked
|
|
642
|
+
)
|
|
643
|
+
.map((item) => item.value);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
field.checked = control.checked;
|
|
647
|
+
field.value = control.checked ? control.value : '';
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
field.value = control.value;
|
|
651
|
+
if (control.type === 'checkbox') field.checked = control.checked;
|
|
526
652
|
});
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
textareaView(field: FormField, id: string): HTMLTextAreaElement {
|
|
530
|
-
return jsx(
|
|
531
|
-
'textarea',
|
|
532
|
-
this.controlProps(field, id, {
|
|
533
|
-
className: field.className || this.state.className.textarea,
|
|
534
|
-
autocomplete: field.autocomplete,
|
|
535
|
-
value: field.value ?? '',
|
|
536
|
-
})
|
|
537
|
-
);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
selectView(field: FormField, id: string): HTMLSelectElement {
|
|
541
|
-
const value = field.value;
|
|
542
|
-
return jsx(
|
|
543
|
-
'select',
|
|
544
|
-
this.controlProps(field, id, {
|
|
545
|
-
className: field.className || this.state.className.select,
|
|
546
|
-
autocomplete: field.autocomplete || 'off',
|
|
547
|
-
multiple: !!field.multiple,
|
|
548
|
-
children: (field.options || []).map((option) => {
|
|
549
|
-
const item = normalizeOption(option);
|
|
550
|
-
return jsx('option', {
|
|
551
|
-
value: item.value ?? '',
|
|
552
|
-
disabled: !!item.disabled,
|
|
553
|
-
selected: this.isSelected(value, item.value),
|
|
554
|
-
children: item.text ?? item.label ?? item.value ?? '',
|
|
555
|
-
});
|
|
556
|
-
}),
|
|
557
|
-
})
|
|
558
|
-
);
|
|
559
|
-
}
|
|
653
|
+
setChainVersion((version: number) => version + 1);
|
|
654
|
+
};
|
|
560
655
|
|
|
561
|
-
|
|
656
|
+
const controlProps = <TExtra extends Record<string, unknown>>(
|
|
657
|
+
field: FormField,
|
|
658
|
+
id: string,
|
|
659
|
+
extra: TExtra
|
|
660
|
+
): TExtra & Record<string, unknown> => ({
|
|
661
|
+
...extra,
|
|
662
|
+
name: field.name,
|
|
663
|
+
id,
|
|
664
|
+
placeholder: field.placeholder || '',
|
|
665
|
+
required: !!field.required,
|
|
666
|
+
disabled: () => state.submitting || !!field.disabled,
|
|
667
|
+
readonly: !!field.readonly,
|
|
668
|
+
'data-form-field': field.name || id,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const choiceGroupView = (
|
|
562
672
|
field: FormField,
|
|
563
673
|
id: string,
|
|
564
674
|
type: 'checkbox' | 'radio'
|
|
565
|
-
): HTMLElement {
|
|
675
|
+
): HTMLElement => {
|
|
566
676
|
const direction = field.vertical ? 'vertical' : 'horizontal';
|
|
567
|
-
const
|
|
568
|
-
const className = joinClasses(
|
|
569
|
-
type === 'radio' ? classNameConfig.radio : classNameConfig.checkbox,
|
|
570
|
-
direction === 'vertical'
|
|
571
|
-
? classNameConfig.choiceVertical
|
|
572
|
-
: classNameConfig.choiceHorizontal,
|
|
573
|
-
field.group ? classNameConfig.choiceGroup : '',
|
|
574
|
-
field.size ? `is-${field.size}` : ''
|
|
575
|
-
);
|
|
576
|
-
|
|
677
|
+
const names = state.className;
|
|
577
678
|
return jsx('div', {
|
|
578
|
-
className
|
|
679
|
+
className: joinClasses(
|
|
680
|
+
type === 'radio' ? names.radio : names.checkbox,
|
|
681
|
+
direction === 'vertical'
|
|
682
|
+
? names.choiceVertical
|
|
683
|
+
: names.choiceHorizontal,
|
|
684
|
+
field.group ? names.choiceGroup : '',
|
|
685
|
+
field.size ? `is-${field.size}` : ''
|
|
686
|
+
),
|
|
579
687
|
'data-form-choice-group': field.name || id,
|
|
580
688
|
'data-choice-type': type,
|
|
581
689
|
'data-choice-layout': direction,
|
|
@@ -583,7 +691,7 @@ class Form extends Component<ResolvedFormProps, FormState, FormDOM> {
|
|
|
583
691
|
const item = normalizeOption(option);
|
|
584
692
|
const optionId = `${id}_${optionIndex}`;
|
|
585
693
|
return jsx('label', {
|
|
586
|
-
className: type === 'radio' ?
|
|
694
|
+
className: type === 'radio' ? names.radioLabel : '',
|
|
587
695
|
'data-form-choice': field.name || id,
|
|
588
696
|
for: optionId,
|
|
589
697
|
children: [
|
|
@@ -594,302 +702,351 @@ class Form extends Component<ResolvedFormProps, FormState, FormDOM> {
|
|
|
594
702
|
value: item.value ?? '',
|
|
595
703
|
checked:
|
|
596
704
|
type === 'radio'
|
|
597
|
-
?
|
|
598
|
-
:
|
|
599
|
-
disabled:
|
|
705
|
+
? isSelected(field.value, item.value)
|
|
706
|
+
: isChecked(field.value, item.value, item.checked),
|
|
707
|
+
disabled: () =>
|
|
708
|
+
state.submitting || !!item.disabled || !!field.disabled,
|
|
600
709
|
required: !!field.required,
|
|
710
|
+
onChange: (event: Event) => {
|
|
711
|
+
const target = event.currentTarget;
|
|
712
|
+
if (target instanceof HTMLInputElement) {
|
|
713
|
+
syncFieldValue(field, target, type);
|
|
714
|
+
}
|
|
715
|
+
},
|
|
601
716
|
}),
|
|
602
717
|
jsx('span', {
|
|
603
|
-
className: type === 'radio' ?
|
|
718
|
+
className: type === 'radio' ? names.radioText : '',
|
|
604
719
|
'data-form-choice-text': '',
|
|
605
720
|
children: item.text ?? item.label ?? item.value ?? '',
|
|
606
721
|
}),
|
|
607
722
|
],
|
|
608
723
|
});
|
|
609
724
|
}),
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
switchView(field: FormField, id: string): HTMLLabelElement {
|
|
614
|
-
const className = this.state.className;
|
|
615
|
-
return jsx('label', {
|
|
616
|
-
className: joinClasses(
|
|
617
|
-
className.switch,
|
|
618
|
-
field.variant ? `is-${field.variant}` : className.switchDefault,
|
|
619
|
-
field.size ? `is-${field.size}` : className.switchSizeMd
|
|
620
|
-
),
|
|
621
|
-
'data-form-switch': field.name || id,
|
|
622
|
-
for: id,
|
|
623
|
-
children: [
|
|
624
|
-
jsx('input', {
|
|
625
|
-
type: 'checkbox',
|
|
626
|
-
id,
|
|
627
|
-
name: field.name,
|
|
628
|
-
value: field.value ?? '1',
|
|
629
|
-
checked: !!field.checked,
|
|
630
|
-
disabled: !!field.disabled,
|
|
631
|
-
required: !!field.required,
|
|
632
|
-
}),
|
|
633
|
-
jsx('span', {
|
|
634
|
-
className: className.switchSlider,
|
|
635
|
-
'data-form-switch-slider': '',
|
|
636
|
-
}),
|
|
637
|
-
],
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
buttonsView(): HTMLElement | null {
|
|
642
|
-
if (!this.state.buttons.length) return null;
|
|
643
|
-
const className = this.state.className;
|
|
644
|
-
|
|
645
|
-
return jsx('div', {
|
|
646
|
-
className: className.buttons,
|
|
647
|
-
'data-form-buttons': '',
|
|
648
|
-
children: this.state.buttons.map((button) =>
|
|
649
|
-
jsx('button', {
|
|
650
|
-
type: button.type || 'button',
|
|
651
|
-
className: joinClasses(
|
|
652
|
-
className.button,
|
|
653
|
-
button.theme ? `is-${button.theme}` : '',
|
|
654
|
-
button.className || ''
|
|
655
|
-
),
|
|
656
|
-
'data-action': button.action || button.type || 'button',
|
|
657
|
-
disabled: () => !!this.state.submitting || !!button.disabled,
|
|
658
|
-
children: button.text ?? button.label ?? '',
|
|
659
|
-
})
|
|
660
|
-
),
|
|
661
|
-
});
|
|
662
|
-
}
|
|
725
|
+
}) as HTMLElement;
|
|
726
|
+
};
|
|
663
727
|
|
|
664
|
-
|
|
728
|
+
const controlView = (
|
|
729
|
+
type: FormItemType,
|
|
665
730
|
field: FormField,
|
|
666
731
|
id: string,
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
'data-form-field': field.name || id,
|
|
687
|
-
ref: (element) => {
|
|
688
|
-
if (field.name) this.dom.fields.set(field.name, element);
|
|
689
|
-
},
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
resolveFieldId(field: FormField, index: number): string {
|
|
694
|
-
if (field.id) return field.id;
|
|
695
|
-
|
|
696
|
-
const key = field.name || index;
|
|
697
|
-
if (!this.cache.fieldIds.has(key)) {
|
|
698
|
-
this.cache.fieldIds.set(key, `${this.state.id}_field_${index}`);
|
|
699
|
-
}
|
|
700
|
-
return this.cache.fieldIds.get(key) || `${this.state.id}_field_${index}`;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
syncValidator(): void {
|
|
704
|
-
if (!this.root) return;
|
|
705
|
-
const options = {
|
|
706
|
-
...this.state.validator,
|
|
707
|
-
onSubmit: null,
|
|
708
|
-
};
|
|
709
|
-
|
|
710
|
-
if (this.validator?.dom.root === this.root) {
|
|
711
|
-
this.validator.props = options;
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
this.validator?.destroy();
|
|
716
|
-
this.validator = createValidator(
|
|
717
|
-
this.root,
|
|
718
|
-
options,
|
|
719
|
-
false
|
|
720
|
-
) as ValidatorInstance;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
validate(): boolean {
|
|
724
|
-
if (!this.validator) return true;
|
|
725
|
-
return this.validator.validate();
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
reset(): this {
|
|
729
|
-
this.validator?.reset();
|
|
730
|
-
flushSync(() => {
|
|
731
|
-
this.state.data = null;
|
|
732
|
-
});
|
|
733
|
-
return this;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
async handleSubmit(event: Event): Promise<void> {
|
|
737
|
-
event.preventDefault();
|
|
738
|
-
if (this.state.submitting) return;
|
|
739
|
-
if (!this.validate()) return;
|
|
740
|
-
|
|
741
|
-
const data = this.collectData();
|
|
742
|
-
flushSync(() => {
|
|
743
|
-
this.state.submitting = true;
|
|
744
|
-
this.state.data = data;
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
try {
|
|
748
|
-
await Promise.resolve(this.state.onSubmit?.(data, this));
|
|
749
|
-
} finally {
|
|
750
|
-
if (!this.runtime.destroyed) {
|
|
751
|
-
flushSync(() => {
|
|
752
|
-
this.state.submitting = false;
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
handleReset(event: Event): void {
|
|
759
|
-
this.resetValidationState();
|
|
760
|
-
flushSync(() => {
|
|
761
|
-
this.state.data = null;
|
|
762
|
-
});
|
|
763
|
-
this.state.onReset?.(event, this);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
resetValidationState(): void {
|
|
767
|
-
if (!this.root) return;
|
|
768
|
-
for (const element of Array.from(this.root.elements)) {
|
|
769
|
-
element.classList.remove('is-valid');
|
|
770
|
-
element.classList.remove('is-invalid');
|
|
732
|
+
index: number,
|
|
733
|
+
item: FormItem<FormField>
|
|
734
|
+
): RenderableContent<FormControlContext> => {
|
|
735
|
+
const names = state.className;
|
|
736
|
+
if (type === 'textarea') {
|
|
737
|
+
return jsx(
|
|
738
|
+
'textarea',
|
|
739
|
+
controlProps(field, id, {
|
|
740
|
+
className: field.className || names.textarea,
|
|
741
|
+
autocomplete: field.autocomplete,
|
|
742
|
+
value: field.value ?? '',
|
|
743
|
+
onInput: (event: Event) => {
|
|
744
|
+
const target = event.currentTarget;
|
|
745
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
746
|
+
syncFieldValue(field, target, 'textarea');
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
})
|
|
750
|
+
) as HTMLTextAreaElement;
|
|
771
751
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
752
|
+
if (type === 'select') {
|
|
753
|
+
return jsx(
|
|
754
|
+
'select',
|
|
755
|
+
controlProps(field, id, {
|
|
756
|
+
className: field.className || names.select,
|
|
757
|
+
autocomplete: field.autocomplete || 'off',
|
|
758
|
+
multiple: !!field.multiple,
|
|
759
|
+
onChange: (event: Event) => {
|
|
760
|
+
const target = event.currentTarget;
|
|
761
|
+
if (target instanceof HTMLSelectElement) {
|
|
762
|
+
syncFieldValue(field, target, 'select');
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
children: (field.options || []).map((option) => {
|
|
766
|
+
const item = normalizeOption(option);
|
|
767
|
+
return jsx('option', {
|
|
768
|
+
value: item.value ?? '',
|
|
769
|
+
disabled: !!item.disabled,
|
|
770
|
+
selected: isSelected(field.value, item.value),
|
|
771
|
+
children: item.text ?? item.label ?? item.value ?? '',
|
|
772
|
+
});
|
|
773
|
+
}),
|
|
774
|
+
})
|
|
775
|
+
) as HTMLSelectElement;
|
|
779
776
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
if (!this.root) return {};
|
|
784
|
-
return this.collectFormData(this.root);
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
collectFormData(form: HTMLFormElement): FormDataRecord {
|
|
788
|
-
const data: FormDataRecord = {};
|
|
789
|
-
const formData = new FormData(form);
|
|
790
|
-
|
|
791
|
-
for (const [key, value] of formData.entries()) {
|
|
792
|
-
if (Object.hasOwn(data, key)) {
|
|
793
|
-
data[key] = Array.isArray(data[key])
|
|
794
|
-
? [...data[key], value]
|
|
795
|
-
: [data[key], value];
|
|
796
|
-
} else {
|
|
797
|
-
data[key] = value;
|
|
798
|
-
}
|
|
777
|
+
if (type === 'radio') return choiceGroupView(field, id, 'radio');
|
|
778
|
+
if (type === 'checkbox' && Array.isArray(field.options)) {
|
|
779
|
+
return choiceGroupView(field, id, 'checkbox');
|
|
799
780
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
781
|
+
if (type === 'switch') {
|
|
782
|
+
return jsx('label', {
|
|
783
|
+
className: joinClasses(
|
|
784
|
+
names.switch,
|
|
785
|
+
field.variant ? `is-${field.variant}` : names.switchDefault,
|
|
786
|
+
field.size ? `is-${field.size}` : names.switchSizeMd
|
|
787
|
+
),
|
|
788
|
+
'data-form-switch': field.name || id,
|
|
789
|
+
for: id,
|
|
790
|
+
children: [
|
|
791
|
+
jsx('input', {
|
|
792
|
+
type: 'checkbox',
|
|
793
|
+
id,
|
|
794
|
+
name: field.name,
|
|
795
|
+
value: field.value ?? '1',
|
|
796
|
+
checked: !!field.checked,
|
|
797
|
+
disabled: () => state.submitting || !!field.disabled,
|
|
798
|
+
required: !!field.required,
|
|
799
|
+
onChange: (event: Event) => {
|
|
800
|
+
const target = event.currentTarget;
|
|
801
|
+
if (target instanceof HTMLInputElement) {
|
|
802
|
+
syncFieldValue(field, target, 'switch');
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
}),
|
|
806
|
+
jsx('span', {
|
|
807
|
+
className: names.switchSlider,
|
|
808
|
+
'data-form-switch-slider': '',
|
|
809
|
+
}),
|
|
810
|
+
],
|
|
811
|
+
}) as HTMLLabelElement;
|
|
809
812
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
setFields(fields: readonly FormField[]): this {
|
|
817
|
-
validateParam('fields', fields, 'array', 'Form.setFields');
|
|
818
|
-
this.cache.fieldIds.clear();
|
|
819
|
-
flushSync(() => {
|
|
820
|
-
this.state.fields = cloneFields(fields);
|
|
821
|
-
});
|
|
822
|
-
return this;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
resetFields(): this {
|
|
826
|
-
return this.setFields(this.cache.initial.fields);
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
cloneProps(props: ResolvedFormProps): ResolvedFormProps {
|
|
830
|
-
return {
|
|
831
|
-
...props,
|
|
832
|
-
fields: cloneFields(props.fields),
|
|
833
|
-
buttons: cloneButtons(props.buttons),
|
|
834
|
-
className: resolveClassNames(props.className),
|
|
835
|
-
validator: cloneValidator(props.validator),
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
autoComplete(type: string): string {
|
|
840
|
-
switch (type) {
|
|
841
|
-
case 'password':
|
|
842
|
-
return 'current-password';
|
|
843
|
-
case 'email':
|
|
844
|
-
return 'email';
|
|
845
|
-
default:
|
|
846
|
-
return 'on';
|
|
813
|
+
if (type === 'custom') {
|
|
814
|
+
return typeof field.content === 'function'
|
|
815
|
+
? field.content({ form, field, index, item })
|
|
816
|
+
: field.content;
|
|
847
817
|
}
|
|
848
|
-
|
|
818
|
+
const inputType = type || 'text';
|
|
819
|
+
return jsx('input', {
|
|
820
|
+
...controlProps(field, id, {
|
|
821
|
+
type: inputType,
|
|
822
|
+
className: field.className || names.input,
|
|
823
|
+
autocomplete: field.autocomplete || autoComplete(inputType),
|
|
824
|
+
value: field.value ?? '',
|
|
825
|
+
onInput: (event: Event) => {
|
|
826
|
+
const target = event.currentTarget;
|
|
827
|
+
if (target instanceof HTMLInputElement) {
|
|
828
|
+
syncFieldValue(field, target, inputType);
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
}),
|
|
832
|
+
checked: field.checked === undefined ? undefined : !!field.checked,
|
|
833
|
+
}) as HTMLInputElement;
|
|
834
|
+
};
|
|
849
835
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
):
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
836
|
+
const fieldView = (
|
|
837
|
+
itemAccessor: () => FormItem<FormField>,
|
|
838
|
+
indexAccessor: () => number
|
|
839
|
+
): HTMLElement => {
|
|
840
|
+
const item = itemAccessor();
|
|
841
|
+
const field = item.payload;
|
|
842
|
+
const fieldAccessor = (): FormField => itemAccessor().payload;
|
|
843
|
+
const index = indexAccessor();
|
|
844
|
+
const id = resolveFieldId(field, index);
|
|
845
|
+
return jsx('div', {
|
|
846
|
+
className: () => state.className.item,
|
|
847
|
+
'data-form-item': () => fieldAccessor().name || String(indexAccessor()),
|
|
848
|
+
style: () => ({
|
|
849
|
+
display: itemAccessor().type === 'hidden' ? 'none' : '',
|
|
850
|
+
}),
|
|
851
|
+
children: [
|
|
852
|
+
() => {
|
|
853
|
+
const current = fieldAccessor();
|
|
854
|
+
if (current.label === false || current.label === undefined) {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
return jsx('label', {
|
|
858
|
+
className: () =>
|
|
859
|
+
joinClasses(
|
|
860
|
+
state.className.label,
|
|
861
|
+
fieldIsRequired(fieldAccessor(), state.validator.rules)
|
|
862
|
+
? state.className.required
|
|
863
|
+
: ''
|
|
864
|
+
),
|
|
865
|
+
'data-form-label': () => fieldAccessor().name || id,
|
|
866
|
+
for: id,
|
|
867
|
+
children: () => fieldAccessor().label,
|
|
868
|
+
});
|
|
869
|
+
},
|
|
870
|
+
jsx('div', {
|
|
871
|
+
className: () => state.className.control,
|
|
872
|
+
'data-form-control': () =>
|
|
873
|
+
fieldAccessor().name || String(indexAccessor()),
|
|
874
|
+
children: [
|
|
875
|
+
controlView(item.type, field, id, index, item),
|
|
876
|
+
() => {
|
|
877
|
+
const current = fieldAccessor();
|
|
878
|
+
return current.help
|
|
879
|
+
? jsx('div', {
|
|
880
|
+
className: 'help-block',
|
|
881
|
+
'data-form-help': () =>
|
|
882
|
+
fieldAccessor().name || String(indexAccessor()),
|
|
883
|
+
children: () => fieldAccessor().help,
|
|
884
|
+
})
|
|
885
|
+
: null;
|
|
886
|
+
},
|
|
887
|
+
],
|
|
888
|
+
}),
|
|
889
|
+
],
|
|
890
|
+
}) as HTMLElement;
|
|
891
|
+
};
|
|
860
892
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
): boolean {
|
|
866
|
-
if (checked !== undefined) return !!checked;
|
|
867
|
-
if (Array.isArray(value))
|
|
868
|
-
return value
|
|
869
|
-
.map((item) => stringifyFormValue(item))
|
|
870
|
-
.includes(stringifyFormValue(optionValue));
|
|
871
|
-
if (
|
|
872
|
-
typeof value !== 'string' &&
|
|
873
|
-
typeof value !== 'boolean' &&
|
|
874
|
-
!(value instanceof File)
|
|
875
|
-
) {
|
|
876
|
-
return false;
|
|
877
|
-
}
|
|
878
|
-
return (
|
|
879
|
-
value === true ||
|
|
880
|
-
stringifyFormValue(value) === stringifyFormValue(optionValue)
|
|
881
|
-
);
|
|
882
|
-
}
|
|
893
|
+
const itemView = (
|
|
894
|
+
itemAccessor: () => FormItem<FormField>,
|
|
895
|
+
indexAccessor: () => number
|
|
896
|
+
): HTMLElement => fieldView(itemAccessor, indexAccessor);
|
|
883
897
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
898
|
+
const buttonsView = (): HTMLElement =>
|
|
899
|
+
jsx('div', {
|
|
900
|
+
className: () => state.className.buttons,
|
|
901
|
+
'data-form-buttons': '',
|
|
902
|
+
hidden: () => state.buttons.length === 0,
|
|
903
|
+
children: For({
|
|
904
|
+
each: () => state.buttons,
|
|
905
|
+
key: (button: FormButton, index: number) =>
|
|
906
|
+
`${button.action || button.type || 'button'}:${index}`,
|
|
907
|
+
children: (buttonAccessor: () => FormButton) =>
|
|
908
|
+
jsx('button', {
|
|
909
|
+
type: () => buttonAccessor().type || 'button',
|
|
910
|
+
className: () => {
|
|
911
|
+
const button = buttonAccessor();
|
|
912
|
+
return joinClasses(
|
|
913
|
+
state.className.button,
|
|
914
|
+
button.theme ? `is-${button.theme}` : '',
|
|
915
|
+
button.className || ''
|
|
916
|
+
);
|
|
917
|
+
},
|
|
918
|
+
'data-action': () => {
|
|
919
|
+
const button = buttonAccessor();
|
|
920
|
+
return button.action || button.type || 'button';
|
|
921
|
+
},
|
|
922
|
+
disabled: () => state.submitting || !!buttonAccessor().disabled,
|
|
923
|
+
children: () => {
|
|
924
|
+
const button = buttonAccessor();
|
|
925
|
+
const content = button.text ?? button.label ?? '';
|
|
926
|
+
const submitting =
|
|
927
|
+
state.submitting &&
|
|
928
|
+
(button.type === 'submit' || button.action === 'submit');
|
|
929
|
+
return submitting ? [createLoading(), content] : content;
|
|
930
|
+
},
|
|
931
|
+
}),
|
|
932
|
+
}),
|
|
933
|
+
}) as HTMLElement;
|
|
934
|
+
|
|
935
|
+
form = defineComponent<
|
|
936
|
+
ResolvedFormProps,
|
|
937
|
+
FormState,
|
|
938
|
+
HTMLFormElement,
|
|
939
|
+
FormActions
|
|
940
|
+
>({
|
|
941
|
+
name: 'Form',
|
|
942
|
+
props,
|
|
943
|
+
state,
|
|
944
|
+
actions: {
|
|
945
|
+
validate,
|
|
946
|
+
reset() {
|
|
947
|
+
validator?.reset();
|
|
948
|
+
flushSync(() => {
|
|
949
|
+
state.data = null;
|
|
950
|
+
});
|
|
951
|
+
return form;
|
|
952
|
+
},
|
|
953
|
+
collectData,
|
|
954
|
+
requestSubmit() {
|
|
955
|
+
const element = form.element;
|
|
956
|
+
if (!element) return form;
|
|
957
|
+
if (typeof element.requestSubmit === 'function')
|
|
958
|
+
element.requestSubmit();
|
|
959
|
+
else
|
|
960
|
+
element.dispatchEvent(
|
|
961
|
+
new Event('submit', { bubbles: true, cancelable: true })
|
|
962
|
+
);
|
|
963
|
+
return form;
|
|
964
|
+
},
|
|
965
|
+
setFields(fields) {
|
|
966
|
+
validateParam('fields', fields, 'array', 'Form.setFields');
|
|
967
|
+
cache.fieldIds.clear();
|
|
968
|
+
runtimeItems = new WeakMap();
|
|
969
|
+
flushSync(() => {
|
|
970
|
+
state.fields = cloneFields(fields);
|
|
971
|
+
});
|
|
972
|
+
return form;
|
|
973
|
+
},
|
|
974
|
+
resetFields() {
|
|
975
|
+
cache.fieldIds.clear();
|
|
976
|
+
runtimeItems = new WeakMap();
|
|
977
|
+
flushSync(() => {
|
|
978
|
+
state.fields = cloneFields(initial.fields);
|
|
979
|
+
});
|
|
980
|
+
return form;
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
normalizeStatePatch(patch) {
|
|
984
|
+
return {
|
|
985
|
+
...patch,
|
|
986
|
+
...(Object.hasOwn(patch, 'fields')
|
|
987
|
+
? { fields: cloneFields(patch.fields) }
|
|
988
|
+
: {}),
|
|
989
|
+
...(Object.hasOwn(patch, 'buttons')
|
|
990
|
+
? { buttons: cloneButtons(patch.buttons) }
|
|
991
|
+
: {}),
|
|
992
|
+
...(Object.hasOwn(patch, 'className')
|
|
993
|
+
? { className: resolveClassNames(patch.className) }
|
|
994
|
+
: {}),
|
|
995
|
+
...(Object.hasOwn(patch, 'validator')
|
|
996
|
+
? { validator: cloneValidator(patch.validator) }
|
|
997
|
+
: {}),
|
|
998
|
+
};
|
|
999
|
+
},
|
|
1000
|
+
view: () => {
|
|
1001
|
+
const element = jsx('form', {
|
|
1002
|
+
className: () =>
|
|
1003
|
+
joinClasses(
|
|
1004
|
+
state.className.form,
|
|
1005
|
+
state.vertical
|
|
1006
|
+
? state.className.vertical
|
|
1007
|
+
: state.className.horizontal,
|
|
1008
|
+
state.itemVertical
|
|
1009
|
+
? state.className.itemVertical
|
|
1010
|
+
: state.className.itemHorizontal
|
|
1011
|
+
),
|
|
1012
|
+
id: () => state.id,
|
|
1013
|
+
'data-form': 'root',
|
|
1014
|
+
'data-form-layout': () => (state.vertical ? 'vertical' : 'horizontal'),
|
|
1015
|
+
'data-form-item-layout': () =>
|
|
1016
|
+
state.itemVertical ? 'vertical' : 'horizontal',
|
|
1017
|
+
onSubmit: (event: Event) => void handleSubmit(event),
|
|
1018
|
+
onReset: (event: Event) => {
|
|
1019
|
+
resetValidationState();
|
|
1020
|
+
flushSync(() => {
|
|
1021
|
+
state.data = null;
|
|
1022
|
+
});
|
|
1023
|
+
state.onReset?.(event, form);
|
|
1024
|
+
},
|
|
1025
|
+
children: [
|
|
1026
|
+
For({
|
|
1027
|
+
each: renderedItems,
|
|
1028
|
+
key: itemKey,
|
|
1029
|
+
children: itemView,
|
|
1030
|
+
}),
|
|
1031
|
+
buttonsView(),
|
|
1032
|
+
],
|
|
1033
|
+
}) as HTMLFormElement;
|
|
1034
|
+
createEffect(() => setElementStyle(element, state.style));
|
|
1035
|
+
createEffect(() => {
|
|
1036
|
+
if (validator) validator.props = { ...state.validator, onSubmit: null };
|
|
1037
|
+
});
|
|
1038
|
+
return element;
|
|
1039
|
+
},
|
|
1040
|
+
onBuild() {
|
|
1041
|
+
syncValidator();
|
|
1042
|
+
},
|
|
1043
|
+
onDestroy() {
|
|
1044
|
+
validator?.destroy();
|
|
1045
|
+
validator = null;
|
|
1046
|
+
cache.fieldIds.clear();
|
|
1047
|
+
runtimeItems = new WeakMap();
|
|
1048
|
+
},
|
|
1049
|
+
}) as Form;
|
|
892
1050
|
|
|
893
|
-
|
|
894
|
-
return new Form(props);
|
|
1051
|
+
return form;
|
|
895
1052
|
}
|