vanilla-jui 1.3.11 → 1.4.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.
Files changed (106) hide show
  1. package/dist/index.d.ts +2921 -0
  2. package/dist/index.js +1 -1
  3. package/dist/index.umd.js +1 -1
  4. package/dist/style.css +1 -1
  5. package/package.json +4 -3
  6. package/src/components/accordion.ts +522 -0
  7. package/src/components/drop.ts +497 -0
  8. package/src/components/flow.ts +1579 -0
  9. package/src/components/form.ts +953 -0
  10. package/src/components/{icons.js → icons.ts} +24 -11
  11. package/src/components/menu.ts +615 -0
  12. package/src/components/{modal.js → modal.ts} +560 -237
  13. package/src/components/offcanvas.ts +457 -0
  14. package/src/components/{pagination.js → pagination.ts} +251 -76
  15. package/src/components/parabola.ts +335 -0
  16. package/src/components/{sticky.js → sticky.ts} +123 -98
  17. package/src/components/{swiper.js → swiper.ts} +510 -232
  18. package/src/components/tabs.ts +984 -0
  19. package/src/components/theme.ts +437 -0
  20. package/src/components/toast.ts +371 -0
  21. package/src/components/toc.ts +385 -0
  22. package/src/components/tooltip.ts +179 -0
  23. package/src/components/validator.ts +596 -0
  24. package/src/core/{Component.js → Component.ts} +134 -27
  25. package/src/css/accordion.css +0 -45
  26. package/src/css/animation.css +31 -1
  27. package/src/css/avatar.css +7 -33
  28. package/src/css/badge.css +11 -59
  29. package/src/css/breadcrumb.css +9 -32
  30. package/src/css/button.css +23 -224
  31. package/src/css/card.css +1 -1
  32. package/src/css/color.css +863 -1013
  33. package/src/css/color_p3.css +822 -975
  34. package/src/css/components.css +1 -0
  35. package/src/css/drop.css +7 -7
  36. package/src/css/flow.css +1 -1
  37. package/src/css/form.css +1 -1
  38. package/src/css/icon.css +7 -12
  39. package/src/css/index.css +5 -2
  40. package/src/css/loading.css +36 -20
  41. package/src/css/menu.css +1 -1
  42. package/src/css/modal.css +15 -44
  43. package/src/css/offcanvas.css +14 -40
  44. package/src/css/popup.css +4 -4
  45. package/src/css/status.css +0 -2
  46. package/src/css/swiper.css +35 -50
  47. package/src/css/tabs.css +9 -31
  48. package/src/css/tag.css +16 -77
  49. package/src/css/themes/default/accordion.css +62 -0
  50. package/src/css/themes/default/avatar.css +39 -0
  51. package/src/css/themes/default/badge.css +63 -0
  52. package/src/css/themes/default/breadcrumb.css +28 -0
  53. package/src/css/themes/default/button.css +204 -0
  54. package/src/css/themes/default/drop.css +11 -0
  55. package/src/css/themes/default/index.css +16 -0
  56. package/src/css/themes/default/loading.css +23 -0
  57. package/src/css/themes/default/modal.css +40 -0
  58. package/src/css/themes/default/offcanvas.css +13 -0
  59. package/src/css/themes/default/popup.css +7 -0
  60. package/src/css/themes/default/swiper.css +35 -0
  61. package/src/css/themes/default/tabs.css +37 -0
  62. package/src/css/themes/default/tag.css +85 -0
  63. package/src/css/themes/default/tip.css +49 -0
  64. package/src/css/themes/default/toast.css +70 -0
  65. package/src/css/themes/default/tooltip.css +14 -0
  66. package/src/css/themes/index.css +1 -0
  67. package/src/css/tip.css +20 -39
  68. package/src/css/toast.css +18 -80
  69. package/src/css/tokens.css +8 -3
  70. package/src/css/tooltip.css +10 -10
  71. package/src/index.ts +37 -0
  72. package/src/locales/{index.js → index.ts} +1 -2
  73. package/src/types.d.ts +1 -0
  74. package/src/utilities/browser.ts +131 -0
  75. package/src/utilities/cache.ts +57 -0
  76. package/src/utilities/core.ts +614 -0
  77. package/src/utilities/dom.ts +468 -0
  78. package/src/utilities/events.ts +106 -0
  79. package/src/utilities/http.ts +35 -0
  80. package/src/utilities/storage.ts +46 -0
  81. package/src/components/accordion.js +0 -373
  82. package/src/components/components.js +0 -0
  83. package/src/components/drop.js +0 -437
  84. package/src/components/flow.js +0 -1133
  85. package/src/components/form.js +0 -585
  86. package/src/components/menu.js +0 -422
  87. package/src/components/offcanvas.js +0 -332
  88. package/src/components/parabola.js +0 -295
  89. package/src/components/tabs.js +0 -746
  90. package/src/components/theme.js +0 -353
  91. package/src/components/toast.js +0 -329
  92. package/src/components/toc.js +0 -226
  93. package/src/components/tooltip.js +0 -86
  94. package/src/components/validator.js +0 -586
  95. package/src/index.js +0 -45
  96. package/src/locales/index.min.js +0 -1
  97. package/src/utilities/browser.js +0 -111
  98. package/src/utilities/cache.js +0 -159
  99. package/src/utilities/core.js +0 -502
  100. package/src/utilities/debug.js +0 -103
  101. package/src/utilities/dom.js +0 -313
  102. package/src/utilities/events.js +0 -69
  103. package/src/utilities/http.js +0 -30
  104. package/src/utilities/legacy-dom.js +0 -179
  105. package/src/utilities/storage.js +0 -46
  106. /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
+ }