vanilla-jui 1.3.11 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2927 -0
- package/dist/index.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/style.css +1 -1
- package/package.json +4 -3
- package/src/components/accordion.ts +522 -0
- package/src/components/drop.ts +497 -0
- package/src/components/flow.ts +1579 -0
- package/src/components/form.ts +953 -0
- package/src/components/{icons.js → icons.ts} +24 -11
- package/src/components/menu.ts +615 -0
- package/src/components/{modal.js → modal.ts} +560 -237
- package/src/components/offcanvas.ts +496 -0
- package/src/components/{pagination.js → pagination.ts} +251 -76
- package/src/components/parabola.ts +335 -0
- package/src/components/{sticky.js → sticky.ts} +123 -98
- package/src/components/{swiper.js → swiper.ts} +510 -232
- package/src/components/tabs.ts +984 -0
- package/src/components/theme.ts +437 -0
- package/src/components/toast.ts +371 -0
- package/src/components/toc.ts +385 -0
- package/src/components/tooltip.ts +179 -0
- package/src/components/validator.ts +596 -0
- package/src/core/{Component.js → Component.ts} +134 -27
- package/src/css/animation.css +31 -1
- package/src/css/card.css +1 -1
- package/src/css/color.css +863 -1013
- package/src/css/color_p3.css +822 -975
- package/src/css/components.css +1 -0
- package/src/css/form.css +1 -1
- package/src/css/icon.css +17 -1
- package/src/css/index.css +2 -2
- package/src/css/menu.css +1 -1
- package/src/css/tabs.css +1 -0
- package/src/css/tokens.css +1 -1
- package/src/index.ts +37 -0
- package/src/locales/{index.js → index.ts} +1 -2
- package/src/types.d.ts +1 -0
- package/src/utilities/browser.ts +131 -0
- package/src/utilities/cache.ts +57 -0
- package/src/utilities/core.ts +614 -0
- package/src/utilities/dom.ts +468 -0
- package/src/utilities/events.ts +106 -0
- package/src/utilities/http.ts +35 -0
- package/src/utilities/storage.ts +46 -0
- package/src/components/accordion.js +0 -373
- package/src/components/drop.js +0 -437
- package/src/components/flow.js +0 -1133
- package/src/components/form.js +0 -585
- package/src/components/menu.js +0 -422
- package/src/components/offcanvas.js +0 -332
- package/src/components/parabola.js +0 -295
- package/src/components/tabs.js +0 -746
- package/src/components/theme.js +0 -353
- package/src/components/toast.js +0 -329
- package/src/components/toc.js +0 -226
- package/src/components/tooltip.js +0 -86
- package/src/components/validator.js +0 -586
- package/src/index.js +0 -45
- package/src/locales/index.min.js +0 -1
- package/src/utilities/browser.js +0 -111
- package/src/utilities/cache.js +0 -159
- package/src/utilities/core.js +0 -502
- package/src/utilities/debug.js +0 -103
- package/src/utilities/dom.js +0 -313
- package/src/utilities/events.js +0 -69
- package/src/utilities/http.js +0 -30
- package/src/utilities/legacy-dom.js +0 -179
- package/src/utilities/storage.js +0 -46
- /package/src/{components/components.js → css/themes/index.css} +0 -0
- /package/src/icons/{index.js → index.ts} +0 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDeepStore,
|
|
3
|
+
createRoot,
|
|
4
|
+
flushSync,
|
|
5
|
+
jsx,
|
|
6
|
+
onCleanup,
|
|
7
|
+
render,
|
|
8
|
+
} from 'vanilla-signal';
|
|
9
|
+
|
|
10
|
+
import Component, { type ComponentDOM } from '../core/Component.ts';
|
|
11
|
+
import {
|
|
12
|
+
type ResolveSchema,
|
|
13
|
+
hasOwn,
|
|
14
|
+
isPlainObject,
|
|
15
|
+
randomId,
|
|
16
|
+
resolveProps,
|
|
17
|
+
validateParam,
|
|
18
|
+
} from '../utilities/core.ts';
|
|
19
|
+
import {
|
|
20
|
+
type DOMReference,
|
|
21
|
+
type RenderableContent,
|
|
22
|
+
all,
|
|
23
|
+
isRenderableContent,
|
|
24
|
+
normalizeContentNodes,
|
|
25
|
+
requireContainer,
|
|
26
|
+
} from '../utilities/dom.ts';
|
|
27
|
+
import { Validator } from './validator.ts';
|
|
28
|
+
|
|
29
|
+
type FormValue = string | number | boolean;
|
|
30
|
+
type FormOptionInput = FormValue | FormOption;
|
|
31
|
+
type FormControlElement =
|
|
32
|
+
| HTMLInputElement
|
|
33
|
+
| HTMLSelectElement
|
|
34
|
+
| HTMLTextAreaElement;
|
|
35
|
+
type FormStyle = string | Partial<CSSStyleDeclaration> | null;
|
|
36
|
+
export type FormDataValue = FormDataEntryValue | FormDataEntryValue[];
|
|
37
|
+
export type FormDataRecord = Record<string, FormDataValue>;
|
|
38
|
+
type FormContainer = Element | DocumentFragment | false | null;
|
|
39
|
+
type FormControlType =
|
|
40
|
+
| 'checkbox'
|
|
41
|
+
| 'custom'
|
|
42
|
+
| 'radio'
|
|
43
|
+
| 'select'
|
|
44
|
+
| 'switch'
|
|
45
|
+
| 'textarea'
|
|
46
|
+
| (string & {});
|
|
47
|
+
|
|
48
|
+
export interface FormClassNames {
|
|
49
|
+
form: string;
|
|
50
|
+
vertical: string;
|
|
51
|
+
horizontal: string;
|
|
52
|
+
itemVertical: string;
|
|
53
|
+
itemHorizontal: string;
|
|
54
|
+
item: string;
|
|
55
|
+
label: string;
|
|
56
|
+
required: string;
|
|
57
|
+
control: string;
|
|
58
|
+
helpInvalid: string;
|
|
59
|
+
buttons: string;
|
|
60
|
+
button: string;
|
|
61
|
+
input: string;
|
|
62
|
+
textarea: string;
|
|
63
|
+
select: string;
|
|
64
|
+
radio: string;
|
|
65
|
+
checkbox: string;
|
|
66
|
+
choiceVertical: string;
|
|
67
|
+
choiceHorizontal: string;
|
|
68
|
+
choiceGroup: string;
|
|
69
|
+
radioLabel: string;
|
|
70
|
+
radioText: string;
|
|
71
|
+
switch: string;
|
|
72
|
+
switchDefault: string;
|
|
73
|
+
switchSizeMd: string;
|
|
74
|
+
switchSlider: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type FormClassNameConfig = Partial<FormClassNames>;
|
|
78
|
+
|
|
79
|
+
export interface FormOption {
|
|
80
|
+
value?: FormValue | FormDataEntryValue | null;
|
|
81
|
+
text?: RenderableContent<Form>;
|
|
82
|
+
label?: RenderableContent<Form>;
|
|
83
|
+
checked?: boolean;
|
|
84
|
+
disabled?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface FormField {
|
|
88
|
+
id?: string;
|
|
89
|
+
label?: RenderableContent<Form> | false;
|
|
90
|
+
name?: string;
|
|
91
|
+
type?: FormControlType;
|
|
92
|
+
options?: readonly FormOptionInput[];
|
|
93
|
+
value?: FormDataEntryValue | boolean | readonly FormDataEntryValue[];
|
|
94
|
+
checked?: boolean;
|
|
95
|
+
required?: boolean;
|
|
96
|
+
placeholder?: string;
|
|
97
|
+
help?: RenderableContent<Form>;
|
|
98
|
+
disabled?: boolean;
|
|
99
|
+
readonly?: boolean;
|
|
100
|
+
autocomplete?: string;
|
|
101
|
+
multiple?: boolean;
|
|
102
|
+
vertical?: boolean;
|
|
103
|
+
group?: boolean;
|
|
104
|
+
size?: string;
|
|
105
|
+
variant?: string;
|
|
106
|
+
className?: string;
|
|
107
|
+
content?: RenderableContent<FormControlContext>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FormButton {
|
|
111
|
+
type?: string;
|
|
112
|
+
text?: RenderableContent<Form>;
|
|
113
|
+
label?: RenderableContent<Form>;
|
|
114
|
+
theme?: string;
|
|
115
|
+
action?: string;
|
|
116
|
+
disabled?: boolean;
|
|
117
|
+
className?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface FormValidatorConfig {
|
|
121
|
+
rules?: Record<string, Record<string, unknown>>;
|
|
122
|
+
messages?: Record<string, Record<string, string>>;
|
|
123
|
+
onSubmit?: (() => void) | null;
|
|
124
|
+
[key: string]: unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface FormProps extends Record<string, unknown> {
|
|
128
|
+
id?: string | null;
|
|
129
|
+
vertical?: boolean;
|
|
130
|
+
itemVertical?: boolean;
|
|
131
|
+
style?: FormStyle;
|
|
132
|
+
fields?: readonly FormField[];
|
|
133
|
+
buttons?: boolean | readonly FormButton[];
|
|
134
|
+
className?: FormClassNameConfig;
|
|
135
|
+
validator?: FormValidatorConfig;
|
|
136
|
+
onSubmit?:
|
|
137
|
+
| ((data: FormDataRecord, form: Form) => void | Promise<void>)
|
|
138
|
+
| null;
|
|
139
|
+
onReset?: ((event: Event, form: Form) => void) | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface ResolvedFormProps extends Record<string, unknown> {
|
|
143
|
+
id: string;
|
|
144
|
+
vertical: boolean;
|
|
145
|
+
itemVertical: boolean;
|
|
146
|
+
style: FormStyle;
|
|
147
|
+
fields: FormField[];
|
|
148
|
+
buttons: FormButton[];
|
|
149
|
+
className: FormClassNames;
|
|
150
|
+
validator: FormValidatorConfig;
|
|
151
|
+
onSubmit: ((data: FormDataRecord, form: Form) => void | Promise<void>) | null;
|
|
152
|
+
onReset: ((event: Event, form: Form) => void) | null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface FormState extends ResolvedFormProps {
|
|
156
|
+
submitting: boolean;
|
|
157
|
+
data: FormDataRecord | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface FormDOM extends ComponentDOM {
|
|
161
|
+
root: HTMLFormElement | null;
|
|
162
|
+
container: DOMReference | DocumentFragment;
|
|
163
|
+
fields: Map<string, FormControlElement>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface FormCache {
|
|
167
|
+
initial: ResolvedFormProps;
|
|
168
|
+
fieldIds: Map<string | number, string>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface ValidatorInstance {
|
|
172
|
+
root: Element | null;
|
|
173
|
+
options: FormValidatorConfig & { onSubmit: null };
|
|
174
|
+
validate: () => boolean;
|
|
175
|
+
reset: () => void;
|
|
176
|
+
destroy: () => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface FormControlContext {
|
|
180
|
+
form: Form;
|
|
181
|
+
field: FormField;
|
|
182
|
+
index: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const DEFAULT_CLASS_NAMES: FormClassNames = {
|
|
186
|
+
form: 'j-form',
|
|
187
|
+
vertical: 'is-vertical',
|
|
188
|
+
horizontal: 'is-horizontal',
|
|
189
|
+
itemVertical: 'is-item-vertical',
|
|
190
|
+
itemHorizontal: 'is-item-horizontal',
|
|
191
|
+
item: 'form-item',
|
|
192
|
+
label: 'item-label',
|
|
193
|
+
required: 'is-required',
|
|
194
|
+
control: 'form-control',
|
|
195
|
+
helpInvalid: 'is-invalid',
|
|
196
|
+
buttons: 'form-buttons',
|
|
197
|
+
button: 'j-button',
|
|
198
|
+
input: 'j-input',
|
|
199
|
+
textarea: 'j-textarea',
|
|
200
|
+
select: 'j-select',
|
|
201
|
+
radio: 'j-radio',
|
|
202
|
+
checkbox: 'j-checkbox',
|
|
203
|
+
choiceVertical: 'is-vertical',
|
|
204
|
+
choiceHorizontal: 'is-horizontal',
|
|
205
|
+
choiceGroup: 'is-group',
|
|
206
|
+
radioLabel: 'radio-label',
|
|
207
|
+
radioText: 'radio-text',
|
|
208
|
+
switch: 'j-switch',
|
|
209
|
+
switchDefault: 'is-default',
|
|
210
|
+
switchSizeMd: 'is-md',
|
|
211
|
+
switchSlider: 'switch-slider',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const DEFAULT_BUTTONS: FormButton[] = [
|
|
215
|
+
{ type: 'submit', text: 'Submit', theme: 'primary', action: 'submit' },
|
|
216
|
+
{ type: 'reset', text: 'Reset', theme: 'ghost', action: 'reset' },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const FORM_PROPS_SCHEMA = {
|
|
220
|
+
id: {
|
|
221
|
+
default: null,
|
|
222
|
+
types: ['string', 'null'],
|
|
223
|
+
normalize: (value: unknown) => {
|
|
224
|
+
if (typeof value === 'string')
|
|
225
|
+
return value.trim() ? value.trim() : randomId();
|
|
226
|
+
if (value == null) return randomId();
|
|
227
|
+
return value;
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
vertical: { default: true, type: 'boolean' },
|
|
231
|
+
itemVertical: { default: true, type: 'boolean' },
|
|
232
|
+
style: { default: '', types: ['string', 'object', 'null'] },
|
|
233
|
+
fields: { default: [], type: 'array' },
|
|
234
|
+
buttons: {
|
|
235
|
+
default: DEFAULT_BUTTONS,
|
|
236
|
+
types: ['boolean', 'array'],
|
|
237
|
+
normalize: (value: unknown) => {
|
|
238
|
+
if (value === false) return [];
|
|
239
|
+
if (value === true) return cloneButtons(DEFAULT_BUTTONS);
|
|
240
|
+
if (Array.isArray(value)) return cloneButtons(value);
|
|
241
|
+
return value;
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
className: {
|
|
245
|
+
default: DEFAULT_CLASS_NAMES,
|
|
246
|
+
type: 'object',
|
|
247
|
+
normalize: (value: unknown) => resolveClassNames(value),
|
|
248
|
+
},
|
|
249
|
+
validator: { default: {}, type: 'object' },
|
|
250
|
+
onSubmit: { default: null, types: ['function', 'null'] },
|
|
251
|
+
onReset: { default: null, types: ['function', 'null'] },
|
|
252
|
+
} satisfies ResolveSchema<FormProps>;
|
|
253
|
+
|
|
254
|
+
const FORM_UPDATE_RULE = {
|
|
255
|
+
validate: (value: unknown) => isPlainObject(value),
|
|
256
|
+
message: 'expects a props object.',
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function cloneOptions(
|
|
260
|
+
options: readonly FormOptionInput[] | undefined
|
|
261
|
+
): FormOptionInput[] {
|
|
262
|
+
if (!Array.isArray(options)) return [];
|
|
263
|
+
return options.map((option) =>
|
|
264
|
+
option && typeof option === 'object' ? { ...option } : option
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function cloneFields(fields: readonly FormField[] | undefined): FormField[] {
|
|
269
|
+
if (!Array.isArray(fields)) return [];
|
|
270
|
+
return fields.map((field) => ({
|
|
271
|
+
...field,
|
|
272
|
+
options: cloneOptions(field.options),
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function cloneButtons(
|
|
277
|
+
buttons: readonly FormButton[] | undefined
|
|
278
|
+
): FormButton[] {
|
|
279
|
+
if (!Array.isArray(buttons)) return [];
|
|
280
|
+
return buttons.map((button) => ({ ...button }));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeOption(option: FormOptionInput): FormOption {
|
|
284
|
+
if (option && typeof option === 'object') return option;
|
|
285
|
+
return { value: option, text: String(option) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function stringifyFormValue(
|
|
289
|
+
value: FormOption['value'] | FormDataEntryValue | undefined
|
|
290
|
+
): string {
|
|
291
|
+
if (value == null) return '';
|
|
292
|
+
if (typeof value === 'string') return value;
|
|
293
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
294
|
+
return String(value);
|
|
295
|
+
}
|
|
296
|
+
return value.name;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function contentView<TContext>(
|
|
300
|
+
content: unknown,
|
|
301
|
+
context: TContext
|
|
302
|
+
): Node[] | null {
|
|
303
|
+
if (!isRenderableContent(content)) return null;
|
|
304
|
+
return normalizeContentNodes(content, context);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function fieldIsRequired(
|
|
308
|
+
field: FormField,
|
|
309
|
+
rules: FormValidatorConfig['rules']
|
|
310
|
+
): boolean {
|
|
311
|
+
if (!field.name) return !!field.required;
|
|
312
|
+
return !!field.required || !!rules?.[field.name]?.required;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function resolveClassName(...parts: Array<string | false | null | undefined>) {
|
|
316
|
+
return parts.filter(Boolean).join(' ');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function resolveClassNames(value: unknown): FormClassNames {
|
|
320
|
+
return {
|
|
321
|
+
...DEFAULT_CLASS_NAMES,
|
|
322
|
+
...(isPlainObject(value) ? value : {}),
|
|
323
|
+
} as FormClassNames;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function setElementStyle(element: HTMLElement, style: FormStyle): void {
|
|
327
|
+
element.removeAttribute('style');
|
|
328
|
+
if (!style) return;
|
|
329
|
+
if (typeof style === 'string') {
|
|
330
|
+
element.style.cssText = style;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!isPlainObject(style)) return;
|
|
334
|
+
for (const [key, value] of Object.entries(style)) {
|
|
335
|
+
if (value == null) continue;
|
|
336
|
+
if (typeof value !== 'string' && typeof value !== 'number') continue;
|
|
337
|
+
const name = key.startsWith('--')
|
|
338
|
+
? key
|
|
339
|
+
: key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
340
|
+
element.style.setProperty(name, String(value));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function cloneValidator(validator: unknown): FormValidatorConfig {
|
|
345
|
+
const source = isPlainObject(validator) ? validator : {};
|
|
346
|
+
return {
|
|
347
|
+
...source,
|
|
348
|
+
rules: isPlainObject(source.rules)
|
|
349
|
+
? (source.rules as FormValidatorConfig['rules'])
|
|
350
|
+
: {},
|
|
351
|
+
messages: isPlainObject(source.messages)
|
|
352
|
+
? (source.messages as FormValidatorConfig['messages'])
|
|
353
|
+
: {},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function normalizeProps(input: FormProps): ResolvedFormProps {
|
|
358
|
+
const props = resolveProps(input, FORM_PROPS_SCHEMA, 'Form.props');
|
|
359
|
+
return {
|
|
360
|
+
...props,
|
|
361
|
+
id: String(props.id),
|
|
362
|
+
vertical: Boolean(props.vertical),
|
|
363
|
+
itemVertical: Boolean(props.itemVertical),
|
|
364
|
+
style: props.style as FormStyle,
|
|
365
|
+
fields: cloneFields(props.fields as readonly FormField[]),
|
|
366
|
+
buttons: cloneButtons(props.buttons as readonly FormButton[]),
|
|
367
|
+
className: resolveClassNames(props.className),
|
|
368
|
+
validator: cloneValidator(props.validator),
|
|
369
|
+
onSubmit: props.onSubmit as ResolvedFormProps['onSubmit'],
|
|
370
|
+
onReset: props.onReset as ResolvedFormProps['onReset'],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export class Form extends Component<ResolvedFormProps, FormState, FormDOM> {
|
|
375
|
+
declare state: FormState;
|
|
376
|
+
validator: ValidatorInstance | null;
|
|
377
|
+
cache: FormCache;
|
|
378
|
+
|
|
379
|
+
constructor(input: FormProps = {}, container: DOMReference = false) {
|
|
380
|
+
const props = normalizeProps(input);
|
|
381
|
+
super(props);
|
|
382
|
+
|
|
383
|
+
this.dom.container = container;
|
|
384
|
+
this.dom.fields = new Map();
|
|
385
|
+
this.validator = null;
|
|
386
|
+
this.cleanup.view = null;
|
|
387
|
+
|
|
388
|
+
this.cache = {
|
|
389
|
+
initial: this.cloneProps(props),
|
|
390
|
+
fieldIds: new Map(),
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
this.state = createDeepStore({
|
|
394
|
+
...this.cloneProps(props),
|
|
395
|
+
submitting: false,
|
|
396
|
+
data: null,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
override get root(): HTMLFormElement | null {
|
|
401
|
+
return this.dom?.root || null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
override set root(value: HTMLFormElement | null) {
|
|
405
|
+
this.dom.root = value;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
protected onInit(): void {
|
|
409
|
+
if (
|
|
410
|
+
this.dom.container === false ||
|
|
411
|
+
this.dom.container == null ||
|
|
412
|
+
typeof this.dom.container === 'string'
|
|
413
|
+
) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this.mount(this.dom.container as FormContainer);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
build(container: DOMReference = this.dom.container): this {
|
|
420
|
+
if (this.runtime.destroyed)
|
|
421
|
+
throw new Error('Form.build: instance destroyed');
|
|
422
|
+
if (this.cleanup.view) return this;
|
|
423
|
+
this.dom.container =
|
|
424
|
+
container === false
|
|
425
|
+
? document.createDocumentFragment()
|
|
426
|
+
: requireContainer(container, 'Form.container');
|
|
427
|
+
this.init(this.props);
|
|
428
|
+
return this;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
mount(container: FormContainer = this.dom.container as FormContainer): this {
|
|
432
|
+
if (this.cleanup.view) return this;
|
|
433
|
+
if (!container) return this;
|
|
434
|
+
|
|
435
|
+
this.cleanup.view = createRoot((dispose) => {
|
|
436
|
+
const viewDispose = render(() => this.view(), container as Element);
|
|
437
|
+
onCleanup(viewDispose);
|
|
438
|
+
return dispose;
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
return this;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
view(): HTMLFormElement {
|
|
445
|
+
const className = this.state.className;
|
|
446
|
+
return jsx('form', {
|
|
447
|
+
className: () =>
|
|
448
|
+
resolveClassName(
|
|
449
|
+
this.state.className.form,
|
|
450
|
+
this.state.vertical ? className.vertical : className.horizontal,
|
|
451
|
+
this.state.itemVertical
|
|
452
|
+
? className.itemVertical
|
|
453
|
+
: className.itemHorizontal
|
|
454
|
+
),
|
|
455
|
+
id: () => this.state.id,
|
|
456
|
+
style: () => this.state.style,
|
|
457
|
+
'data-form': 'root',
|
|
458
|
+
'data-form-layout': () =>
|
|
459
|
+
this.state.vertical ? 'vertical' : 'horizontal',
|
|
460
|
+
'data-form-item-layout': () =>
|
|
461
|
+
this.state.itemVertical ? 'vertical' : 'horizontal',
|
|
462
|
+
ref: (element: HTMLFormElement) => {
|
|
463
|
+
const changed = this.root !== element;
|
|
464
|
+
this.root = element;
|
|
465
|
+
setElementStyle(element, this.state.style);
|
|
466
|
+
if (changed || !this.validator) this.syncValidator();
|
|
467
|
+
},
|
|
468
|
+
onSubmit: (event: Event) => this.handleSubmit(event),
|
|
469
|
+
onReset: (event: Event) => this.handleReset(event),
|
|
470
|
+
children: () => [
|
|
471
|
+
...this.state.fields.map((field, index) =>
|
|
472
|
+
this.fieldView(field, index)
|
|
473
|
+
),
|
|
474
|
+
this.buttonsView(),
|
|
475
|
+
],
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
fieldView(field: FormField, index: number): HTMLElement {
|
|
480
|
+
const id = this.resolveFieldId(field, index);
|
|
481
|
+
const hidden = field.type === 'hidden';
|
|
482
|
+
const className = this.state.className;
|
|
483
|
+
|
|
484
|
+
return jsx('div', {
|
|
485
|
+
className: className.item,
|
|
486
|
+
'data-form-item': field.name || String(index),
|
|
487
|
+
style: { display: hidden ? 'none' : '' },
|
|
488
|
+
children: [
|
|
489
|
+
this.labelView(field, id),
|
|
490
|
+
jsx('div', {
|
|
491
|
+
className: className.control,
|
|
492
|
+
'data-form-control': field.name || String(index),
|
|
493
|
+
children: [
|
|
494
|
+
this.controlView(field, id, index),
|
|
495
|
+
field.help
|
|
496
|
+
? jsx('div', {
|
|
497
|
+
className: 'help-block',
|
|
498
|
+
'data-form-help': field.name || String(index),
|
|
499
|
+
children: field.help,
|
|
500
|
+
})
|
|
501
|
+
: null,
|
|
502
|
+
],
|
|
503
|
+
}),
|
|
504
|
+
],
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
labelView(field: FormField, id: string): HTMLLabelElement | null {
|
|
509
|
+
if (field.label === false || field.label === undefined) return null;
|
|
510
|
+
const className = this.state.className;
|
|
511
|
+
|
|
512
|
+
return jsx('label', {
|
|
513
|
+
className: resolveClassName(
|
|
514
|
+
className.label,
|
|
515
|
+
fieldIsRequired(field, this.state.validator?.rules)
|
|
516
|
+
? className.required
|
|
517
|
+
: ''
|
|
518
|
+
),
|
|
519
|
+
'data-form-label': field.name || id,
|
|
520
|
+
for: id,
|
|
521
|
+
children: field.label,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
controlView(
|
|
526
|
+
field: FormField,
|
|
527
|
+
id: string,
|
|
528
|
+
index: number
|
|
529
|
+
): FormControlElement | HTMLElement | Node[] | null {
|
|
530
|
+
switch (field.type) {
|
|
531
|
+
case 'textarea':
|
|
532
|
+
return this.textareaView(field, id);
|
|
533
|
+
case 'select':
|
|
534
|
+
return this.selectView(field, id);
|
|
535
|
+
case 'radio':
|
|
536
|
+
return this.choiceGroupView(field, id, 'radio');
|
|
537
|
+
case 'checkbox':
|
|
538
|
+
return Array.isArray(field.options)
|
|
539
|
+
? this.choiceGroupView(field, id, 'checkbox')
|
|
540
|
+
: this.inputView(field, id);
|
|
541
|
+
case 'switch':
|
|
542
|
+
return this.switchView(field, id);
|
|
543
|
+
case 'custom':
|
|
544
|
+
return contentView(field.content, { form: this, field, index });
|
|
545
|
+
default:
|
|
546
|
+
return this.inputView(field, id);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
inputView(field: FormField, id: string): HTMLInputElement {
|
|
551
|
+
const type = field.type || 'text';
|
|
552
|
+
const props = this.controlProps(field, id, {
|
|
553
|
+
type,
|
|
554
|
+
className: field.className || this.state.className.input,
|
|
555
|
+
autocomplete: field.autocomplete || this.autoComplete(type),
|
|
556
|
+
value: field.value ?? '',
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
return jsx('input', {
|
|
560
|
+
...props,
|
|
561
|
+
checked: field.checked === undefined ? undefined : !!field.checked,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
textareaView(field: FormField, id: string): HTMLTextAreaElement {
|
|
566
|
+
return jsx(
|
|
567
|
+
'textarea',
|
|
568
|
+
this.controlProps(field, id, {
|
|
569
|
+
className: field.className || this.state.className.textarea,
|
|
570
|
+
autocomplete: field.autocomplete,
|
|
571
|
+
value: field.value ?? '',
|
|
572
|
+
})
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
selectView(field: FormField, id: string): HTMLSelectElement {
|
|
577
|
+
const value = field.value;
|
|
578
|
+
return jsx(
|
|
579
|
+
'select',
|
|
580
|
+
this.controlProps(field, id, {
|
|
581
|
+
className: field.className || this.state.className.select,
|
|
582
|
+
autocomplete: field.autocomplete || 'off',
|
|
583
|
+
multiple: !!field.multiple,
|
|
584
|
+
children: (field.options || []).map((option) => {
|
|
585
|
+
const item = normalizeOption(option);
|
|
586
|
+
return jsx('option', {
|
|
587
|
+
value: item.value ?? '',
|
|
588
|
+
disabled: !!item.disabled,
|
|
589
|
+
selected: this.isSelected(value, item.value),
|
|
590
|
+
children: item.text ?? item.label ?? item.value ?? '',
|
|
591
|
+
});
|
|
592
|
+
}),
|
|
593
|
+
})
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
choiceGroupView(
|
|
598
|
+
field: FormField,
|
|
599
|
+
id: string,
|
|
600
|
+
type: 'checkbox' | 'radio'
|
|
601
|
+
): HTMLElement {
|
|
602
|
+
const direction = field.vertical ? 'vertical' : 'horizontal';
|
|
603
|
+
const classNameConfig = this.state.className;
|
|
604
|
+
const className = resolveClassName(
|
|
605
|
+
type === 'radio' ? classNameConfig.radio : classNameConfig.checkbox,
|
|
606
|
+
direction === 'vertical'
|
|
607
|
+
? classNameConfig.choiceVertical
|
|
608
|
+
: classNameConfig.choiceHorizontal,
|
|
609
|
+
field.group ? classNameConfig.choiceGroup : '',
|
|
610
|
+
field.size ? `is-${field.size}` : ''
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
return jsx('div', {
|
|
614
|
+
className,
|
|
615
|
+
'data-form-choice-group': field.name || id,
|
|
616
|
+
'data-choice-type': type,
|
|
617
|
+
'data-choice-layout': direction,
|
|
618
|
+
children: (field.options || []).map((option, optionIndex) => {
|
|
619
|
+
const item = normalizeOption(option);
|
|
620
|
+
const optionId = `${id}_${optionIndex}`;
|
|
621
|
+
return jsx('label', {
|
|
622
|
+
className: type === 'radio' ? classNameConfig.radioLabel : '',
|
|
623
|
+
'data-form-choice': field.name || id,
|
|
624
|
+
for: optionId,
|
|
625
|
+
children: [
|
|
626
|
+
jsx('input', {
|
|
627
|
+
type,
|
|
628
|
+
id: optionId,
|
|
629
|
+
name: field.name,
|
|
630
|
+
value: item.value ?? '',
|
|
631
|
+
checked:
|
|
632
|
+
type === 'radio'
|
|
633
|
+
? this.isSelected(field.value, item.value)
|
|
634
|
+
: this.isChecked(field.value, item.value, item.checked),
|
|
635
|
+
disabled: !!item.disabled || !!field.disabled,
|
|
636
|
+
required: !!field.required,
|
|
637
|
+
}),
|
|
638
|
+
jsx('span', {
|
|
639
|
+
className: type === 'radio' ? classNameConfig.radioText : '',
|
|
640
|
+
'data-form-choice-text': '',
|
|
641
|
+
children: item.text ?? item.label ?? item.value ?? '',
|
|
642
|
+
}),
|
|
643
|
+
],
|
|
644
|
+
});
|
|
645
|
+
}),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
switchView(field: FormField, id: string): HTMLLabelElement {
|
|
650
|
+
const className = this.state.className;
|
|
651
|
+
return jsx('label', {
|
|
652
|
+
className: resolveClassName(
|
|
653
|
+
className.switch,
|
|
654
|
+
field.variant ? `is-${field.variant}` : className.switchDefault,
|
|
655
|
+
field.size ? `is-${field.size}` : className.switchSizeMd
|
|
656
|
+
),
|
|
657
|
+
'data-form-switch': field.name || id,
|
|
658
|
+
for: id,
|
|
659
|
+
children: [
|
|
660
|
+
jsx('input', {
|
|
661
|
+
type: 'checkbox',
|
|
662
|
+
id,
|
|
663
|
+
name: field.name,
|
|
664
|
+
value: field.value ?? 'on',
|
|
665
|
+
checked: !!field.checked,
|
|
666
|
+
disabled: !!field.disabled,
|
|
667
|
+
required: !!field.required,
|
|
668
|
+
}),
|
|
669
|
+
jsx('span', {
|
|
670
|
+
className: className.switchSlider,
|
|
671
|
+
'data-form-switch-slider': '',
|
|
672
|
+
}),
|
|
673
|
+
],
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
buttonsView(): HTMLElement | null {
|
|
678
|
+
if (!this.state.buttons.length) return null;
|
|
679
|
+
const className = this.state.className;
|
|
680
|
+
|
|
681
|
+
return jsx('div', {
|
|
682
|
+
className: className.buttons,
|
|
683
|
+
'data-form-buttons': '',
|
|
684
|
+
children: this.state.buttons.map((button) =>
|
|
685
|
+
jsx('button', {
|
|
686
|
+
type: button.type || 'button',
|
|
687
|
+
className: resolveClassName(
|
|
688
|
+
className.button,
|
|
689
|
+
button.theme ? `is-${button.theme}` : '',
|
|
690
|
+
button.className || ''
|
|
691
|
+
),
|
|
692
|
+
'data-action': button.action || button.type || 'button',
|
|
693
|
+
disabled: () => !!this.state.submitting || !!button.disabled,
|
|
694
|
+
children: button.text ?? button.label ?? '',
|
|
695
|
+
})
|
|
696
|
+
),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
controlProps<TExtra extends Record<string, unknown>>(
|
|
701
|
+
field: FormField,
|
|
702
|
+
id: string,
|
|
703
|
+
extra: TExtra
|
|
704
|
+
): TExtra & {
|
|
705
|
+
name: string | undefined;
|
|
706
|
+
id: string;
|
|
707
|
+
placeholder: string;
|
|
708
|
+
required: boolean;
|
|
709
|
+
disabled: boolean;
|
|
710
|
+
readonly: boolean;
|
|
711
|
+
'data-form-field': string;
|
|
712
|
+
ref: (element: FormControlElement) => void;
|
|
713
|
+
} {
|
|
714
|
+
return {
|
|
715
|
+
...extra,
|
|
716
|
+
name: field.name,
|
|
717
|
+
id,
|
|
718
|
+
placeholder: field.placeholder || '',
|
|
719
|
+
required: !!field.required,
|
|
720
|
+
disabled: !!field.disabled,
|
|
721
|
+
readonly: !!field.readonly,
|
|
722
|
+
'data-form-field': field.name || id,
|
|
723
|
+
ref: (element) => {
|
|
724
|
+
if (field.name) this.dom.fields.set(field.name, element);
|
|
725
|
+
},
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
resolveFieldId(field: FormField, index: number): string {
|
|
730
|
+
if (field.id) return field.id;
|
|
731
|
+
|
|
732
|
+
const key = field.name || index;
|
|
733
|
+
if (!this.cache.fieldIds.has(key)) {
|
|
734
|
+
this.cache.fieldIds.set(key, `${this.state.id}_field_${index}`);
|
|
735
|
+
}
|
|
736
|
+
return this.cache.fieldIds.get(key) || `${this.state.id}_field_${index}`;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
syncValidator(): void {
|
|
740
|
+
if (!this.root) return;
|
|
741
|
+
const options = {
|
|
742
|
+
...this.state.validator,
|
|
743
|
+
onSubmit: null,
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
if (this.validator?.root === this.root) {
|
|
747
|
+
this.validator.options = options;
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
this.validator?.destroy();
|
|
752
|
+
this.validator = new Validator(
|
|
753
|
+
this.root,
|
|
754
|
+
options,
|
|
755
|
+
false
|
|
756
|
+
) as ValidatorInstance;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
validate(): boolean {
|
|
760
|
+
if (!this.validator) return true;
|
|
761
|
+
return this.validator.validate();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
reset(): this {
|
|
765
|
+
this.validator?.reset();
|
|
766
|
+
flushSync(() => {
|
|
767
|
+
this.state.data = null;
|
|
768
|
+
});
|
|
769
|
+
return this;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async handleSubmit(event: Event): Promise<void> {
|
|
773
|
+
event.preventDefault();
|
|
774
|
+
if (this.state.submitting) return;
|
|
775
|
+
if (!this.validate()) return;
|
|
776
|
+
|
|
777
|
+
const data = this.collectData();
|
|
778
|
+
flushSync(() => {
|
|
779
|
+
this.state.submitting = true;
|
|
780
|
+
this.state.data = data;
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
await Promise.resolve(this.state.onSubmit?.(data, this));
|
|
785
|
+
} finally {
|
|
786
|
+
if (!this.runtime.destroyed) {
|
|
787
|
+
flushSync(() => {
|
|
788
|
+
this.state.submitting = false;
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
handleReset(event: Event): void {
|
|
795
|
+
this.resetValidationState();
|
|
796
|
+
flushSync(() => {
|
|
797
|
+
this.state.data = null;
|
|
798
|
+
});
|
|
799
|
+
this.state.onReset?.(event, this);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
resetValidationState(): void {
|
|
803
|
+
if (!this.root) return;
|
|
804
|
+
for (const element of Array.from(this.root.elements)) {
|
|
805
|
+
element.classList.remove('is-valid');
|
|
806
|
+
element.classList.remove('is-invalid');
|
|
807
|
+
}
|
|
808
|
+
for (const help of all<HTMLElement>(
|
|
809
|
+
'[data-form-help], [data-validator-help]',
|
|
810
|
+
this.root
|
|
811
|
+
)) {
|
|
812
|
+
if (help.dataset.validatorHelp || help.classList.contains('is-invalid')) {
|
|
813
|
+
help.remove();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
collectData(): FormDataRecord {
|
|
819
|
+
if (!this.root) return {};
|
|
820
|
+
return this.collectFormData(this.root);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
collectFormData(form: HTMLFormElement): FormDataRecord {
|
|
824
|
+
const data: FormDataRecord = {};
|
|
825
|
+
const formData = new FormData(form);
|
|
826
|
+
|
|
827
|
+
for (const [key, value] of formData.entries()) {
|
|
828
|
+
if (hasOwn(data, key)) {
|
|
829
|
+
data[key] = Array.isArray(data[key])
|
|
830
|
+
? [...data[key], value]
|
|
831
|
+
: [data[key], value];
|
|
832
|
+
} else {
|
|
833
|
+
data[key] = value;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return data;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
requestSubmit(): this {
|
|
841
|
+
if (!this.root) return this;
|
|
842
|
+
if (typeof this.root.requestSubmit === 'function') {
|
|
843
|
+
this.root.requestSubmit();
|
|
844
|
+
return this;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const event = new Event('submit', { bubbles: true, cancelable: true });
|
|
848
|
+
this.root.dispatchEvent(event);
|
|
849
|
+
return this;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
override update(
|
|
853
|
+
patch: Partial<FormProps> | null | undefined = {},
|
|
854
|
+
_options = {}
|
|
855
|
+
): this {
|
|
856
|
+
const propsPatch = patch || {};
|
|
857
|
+
validateParam('props', propsPatch, FORM_UPDATE_RULE, 'Form.update');
|
|
858
|
+
const nextProps = normalizeProps(Object.assign({}, this.props, propsPatch));
|
|
859
|
+
|
|
860
|
+
this.props = this.cloneProps(nextProps);
|
|
861
|
+
this.cache.fieldIds.clear();
|
|
862
|
+
super.update(nextProps);
|
|
863
|
+
|
|
864
|
+
flushSync(() => {
|
|
865
|
+
const statePatch = this.cloneProps(nextProps);
|
|
866
|
+
for (const key of Object.keys(statePatch) as Array<
|
|
867
|
+
keyof ResolvedFormProps
|
|
868
|
+
>) {
|
|
869
|
+
this.state[key] = statePatch[key] as FormState[typeof key];
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
return this;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
setFields(fields: readonly FormField[]): this {
|
|
876
|
+
validateParam('fields', fields, 'array', 'Form.setFields');
|
|
877
|
+
return this.update({ fields });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
resetFields(): this {
|
|
881
|
+
return this.setFields(this.cache.initial.fields);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
cloneProps(props: ResolvedFormProps): ResolvedFormProps {
|
|
885
|
+
return {
|
|
886
|
+
...props,
|
|
887
|
+
fields: cloneFields(props.fields),
|
|
888
|
+
buttons: cloneButtons(props.buttons),
|
|
889
|
+
className: resolveClassNames(props.className),
|
|
890
|
+
validator: cloneValidator(props.validator),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
autoComplete(type: string): string {
|
|
895
|
+
switch (type) {
|
|
896
|
+
case 'password':
|
|
897
|
+
return 'current-password';
|
|
898
|
+
case 'email':
|
|
899
|
+
return 'email';
|
|
900
|
+
default:
|
|
901
|
+
return 'on';
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
isSelected(
|
|
906
|
+
value: FormField['value'] | undefined,
|
|
907
|
+
optionValue: FormOption['value']
|
|
908
|
+
): boolean {
|
|
909
|
+
if (Array.isArray(value))
|
|
910
|
+
return value
|
|
911
|
+
.map((item) => stringifyFormValue(item))
|
|
912
|
+
.includes(stringifyFormValue(optionValue));
|
|
913
|
+
return value == optionValue;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
isChecked(
|
|
917
|
+
value: FormField['value'] | undefined,
|
|
918
|
+
optionValue: FormOption['value'],
|
|
919
|
+
checked: boolean | undefined
|
|
920
|
+
): boolean {
|
|
921
|
+
if (checked !== undefined) return !!checked;
|
|
922
|
+
if (Array.isArray(value))
|
|
923
|
+
return value
|
|
924
|
+
.map((item) => stringifyFormValue(item))
|
|
925
|
+
.includes(stringifyFormValue(optionValue));
|
|
926
|
+
if (
|
|
927
|
+
typeof value !== 'string' &&
|
|
928
|
+
typeof value !== 'boolean' &&
|
|
929
|
+
!(value instanceof File)
|
|
930
|
+
) {
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
return (
|
|
934
|
+
value === true ||
|
|
935
|
+
stringifyFormValue(value) === stringifyFormValue(optionValue)
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
protected onDestroy(): void {
|
|
940
|
+
const cleanupView = this.cleanup.view;
|
|
941
|
+
if (typeof cleanupView === 'function') cleanupView();
|
|
942
|
+
this.cleanup.view = null;
|
|
943
|
+
this.validator?.destroy();
|
|
944
|
+
this.validator = null;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export function createForm(
|
|
949
|
+
props: FormProps = {},
|
|
950
|
+
container: DOMReference = false
|
|
951
|
+
): Form {
|
|
952
|
+
return new Form(props, container);
|
|
953
|
+
}
|