@xelqun/forms 0.1.1 → 0.2.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.
Files changed (76) hide show
  1. package/README.md +46 -61
  2. package/package.json +32 -2
  3. package/resources/css/xelqun.css +121 -0
  4. package/resources/js/components/XqAction.vue +143 -0
  5. package/resources/js/components/XqFieldSlot.vue +20 -0
  6. package/resources/js/components/XqFieldWrapper.vue +74 -30
  7. package/resources/js/components/XqForm.vue +36 -9
  8. package/resources/js/components/XqNode.vue +8 -1
  9. package/resources/js/components/XqUnknownNode.vue +2 -1
  10. package/resources/js/components/defaults.ts +36 -0
  11. package/resources/js/components/fields/XqBlockPicker.vue +68 -0
  12. package/resources/js/components/fields/XqCheckboxField.vue +35 -9
  13. package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
  14. package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
  15. package/resources/js/components/fields/XqColorPickerField.vue +156 -0
  16. package/resources/js/components/fields/XqDatePickerField.vue +149 -53
  17. package/resources/js/components/fields/XqFileUploadField.vue +524 -165
  18. package/resources/js/components/fields/XqImageEditor.vue +165 -0
  19. package/resources/js/components/fields/XqKeyValueField.vue +168 -0
  20. package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
  21. package/resources/js/components/fields/XqRadioField.vue +9 -5
  22. package/resources/js/components/fields/XqRepeaterField.vue +314 -79
  23. package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
  24. package/resources/js/components/fields/XqRichEditorField.vue +280 -0
  25. package/resources/js/components/fields/XqSelectField.vue +29 -12
  26. package/resources/js/components/fields/XqSliderField.vue +47 -0
  27. package/resources/js/components/fields/XqTagsInputField.vue +127 -0
  28. package/resources/js/components/fields/XqTextInputField.vue +121 -12
  29. package/resources/js/components/fields/XqTextareaField.vue +39 -16
  30. package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
  31. package/resources/js/components/fields/XqToggleField.vue +33 -9
  32. package/resources/js/components/layouts/XqActions.vue +19 -0
  33. package/resources/js/components/layouts/XqBlock.vue +52 -0
  34. package/resources/js/components/layouts/XqCallout.vue +46 -0
  35. package/resources/js/components/layouts/XqEmptyState.vue +37 -0
  36. package/resources/js/components/layouts/XqFlex.vue +23 -0
  37. package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
  38. package/resources/js/components/layouts/XqIconNode.vue +32 -0
  39. package/resources/js/components/layouts/XqImageNode.vue +30 -0
  40. package/resources/js/components/layouts/XqSection.vue +3 -1
  41. package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
  42. package/resources/js/components/layouts/XqTextNode.vue +49 -0
  43. package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
  44. package/resources/js/components/layouts/XqWizard.vue +168 -0
  45. package/resources/js/composables/context.ts +14 -0
  46. package/resources/js/composables/useField.ts +21 -3
  47. package/resources/js/composables/usePanelErrors.ts +37 -0
  48. package/resources/js/composables/useSortable.ts +70 -0
  49. package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
  50. package/resources/js/core/dates.ts +38 -0
  51. package/resources/js/core/images.ts +110 -0
  52. package/resources/js/core/log.ts +1 -1
  53. package/resources/js/core/markdown.ts +61 -0
  54. package/resources/js/core/mask.ts +42 -0
  55. package/resources/js/core/patch.ts +6 -3
  56. package/resources/js/core/paths.ts +11 -14
  57. package/resources/js/core/store.ts +515 -25
  58. package/resources/js/core/translate.ts +17 -0
  59. package/resources/js/core/validation.ts +126 -0
  60. package/resources/js/index.ts +4 -3
  61. package/resources/js/lib/utils.ts +15 -0
  62. package/resources/js/plugin.ts +103 -6
  63. package/resources/js/schema/types.ts +102 -0
  64. package/resources/js/testing.ts +50 -0
  65. package/resources/js/ui/XqCalendar.vue +4 -0
  66. package/resources/js/ui/XqCombobox.vue +89 -32
  67. package/resources/js/ui/XqConfirmDialog.vue +58 -0
  68. package/resources/js/ui/XqDialog.vue +46 -0
  69. package/resources/js/ui/XqIcon.vue +28 -0
  70. package/resources/js/ui/XqSelect.vue +38 -18
  71. package/resources/js/ui/XqTabs.vue +1 -1
  72. package/resources/js/ui/XqTimeField.vue +66 -0
  73. package/resources/js/ui/classes.ts +33 -0
  74. package/resources/js/ui/icons.ts +95 -0
  75. package/resources/js/ui/index.ts +5 -0
  76. package/resources/js/ui/options.ts +46 -0
@@ -1,15 +1,20 @@
1
1
  import { router } from '@inertiajs/vue3';
2
- import { effectScope, nextTick, reactive, ref, watch } from 'vue';
2
+ import { effectScope, nextTick, reactive, ref, shallowReactive, watch } from 'vue';
3
3
  import type { Ref } from 'vue';
4
4
  import type {
5
+ ActionRequest,
6
+ ActionResult,
7
+ BlockOption,
5
8
  ConditionNode,
6
9
  FormSchema,
7
10
  LiveConfig,
8
11
  SchemaNode,
9
12
  SelectOption,
13
+ TextKey,
10
14
  UpdatePatch,
11
15
  UpdateRequest,
12
16
  UploadResult,
17
+ ValidateRequest,
13
18
  } from '../schema/types';
14
19
  import { SUPPORTED_SCHEMA_VERSIONS } from '../schema/types';
15
20
  import { evaluateCondition } from './conditions';
@@ -20,10 +25,19 @@ import { clone, deletePath, getPath, overlaps, replaceRow, setPath, startsWithPa
20
25
  import { RequestLog } from './log';
21
26
  import { RequestQueue } from './queue';
22
27
  import { newRowKey } from './rows';
28
+ import { translate } from './translate';
29
+ import type { Replacements } from './translate';
30
+ import { firstFailure } from './validation';
23
31
  import { isPlainObject } from './values';
24
32
 
25
33
  export type SetSource = 'user' | 'server' | 'computed';
26
34
 
35
+ /**
36
+ * How long a request runs before its spinner appears, so fast ones never flash.
37
+ * The same 200ms as Livewire's wire:loading.delay.
38
+ */
39
+ export const LOADING_INDICATOR_DELAY = 200;
40
+
27
41
  export interface SubmitOptions {
28
42
  method?: 'post' | 'put' | 'patch';
29
43
  data?: Record<string, unknown>;
@@ -33,6 +47,23 @@ export interface SubmitOptions {
33
47
  onFinish?: () => void;
34
48
  }
35
49
 
50
+ /** A multi-step layout. While it has steps left, submitting moves to the next one. */
51
+ export interface Stepper {
52
+ hasNext: () => boolean;
53
+ next: () => Promise<void>;
54
+ }
55
+
56
+ /** The part of a modal's form store its action needs. */
57
+ export interface ActionModal {
58
+ state: { data: Record<string, any> };
59
+ settled: () => Promise<void>;
60
+ validate: () => boolean;
61
+ addErrors: (incoming: Record<string, string | string[]>) => void;
62
+ focusFirstError: () => void;
63
+ fatalError: Ref<string | null>;
64
+ liveError: Ref<string | null>;
65
+ }
66
+
36
67
  export interface FormStoreOptions {
37
68
  send?: (url: string, request: UpdateRequest, signal: AbortSignal) => Promise<UpdatePatch>;
38
69
  }
@@ -80,6 +111,24 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
80
111
  const root: Ref<HTMLElement | null> = ref(null);
81
112
  const uploads = new Set<Promise<unknown>>();
82
113
  const uploading = ref(0);
114
+ const steppers = shallowReactive(new Set<Stepper>());
115
+ const validating = ref(false);
116
+ let cleanState: string | null = null;
117
+
118
+ function t(text: string, replacements?: Replacements): string {
119
+ return translate(schema.messages, text, replacements);
120
+ }
121
+
122
+ /**
123
+ * Treat the current values as saved.
124
+ */
125
+ function markClean(): void {
126
+ cleanState = JSON.stringify(state.data);
127
+ }
128
+
129
+ function isDirty(): boolean {
130
+ return cleanState !== null && JSON.stringify(state.data) !== cleanState;
131
+ }
83
132
 
84
133
  function get(path: string): any {
85
134
  return getPath(state, path);
@@ -157,6 +206,19 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
157
206
  return !!node.disabled || condition(node.rules?.disabled, false);
158
207
  }
159
208
 
209
+ /**
210
+ * The label, hint or placeholder to show, following any browser-side condition.
211
+ */
212
+ function text(node: SchemaNode, key: TextKey): string | null {
213
+ const rule = node.rules?.text?.[key];
214
+
215
+ if (!rule) {
216
+ return node[key] ?? null;
217
+ }
218
+
219
+ return evaluateCondition(rule.when, get) ? rule.text : rule.otherwise;
220
+ }
221
+
160
222
  function index(list: SchemaNode[]): void {
161
223
  eachNode(list, (node) => {
162
224
  nodes.set(node.key, node);
@@ -179,7 +241,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
179
241
  });
180
242
  }
181
243
 
182
- if (node.type === 'repeater' && node.path && !effects.has(`${node.key}::rows`)) {
244
+ if (node.containsRows && node.path && !effects.has(`${node.key}::rows`)) {
183
245
  const path = node.path;
184
246
 
185
247
  scope.run(() => {
@@ -217,6 +279,19 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
217
279
  return node.props.rows;
218
280
  }
219
281
 
282
+ function blockOf(node: SchemaNode, name: unknown): BlockOption | undefined {
283
+ return node.props?.blocks?.find((block: BlockOption) => block.name === name);
284
+ }
285
+
286
+ /**
287
+ * A builder's row is built from its block, a repeater's from one template.
288
+ */
289
+ function rowTemplate(node: SchemaNode, key: string): SchemaNode[] {
290
+ return (
291
+ blockOf(node, get(`${node.path}.${key}.type`))?.template ?? node.props?.template ?? []
292
+ );
293
+ }
294
+
220
295
  /**
221
296
  * Make the repeater's row nodes match the row keys in state.
222
297
  */
@@ -225,7 +300,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
225
300
 
226
301
  for (const key of keys) {
227
302
  if (!rows[key]) {
228
- rows[key] = replaceRow(node.props?.template ?? [], key);
303
+ rows[key] = replaceRow(rowTemplate(node, key), key, node.props?.rowPlaceholder);
229
304
  index(rows[key]);
230
305
  }
231
306
  }
@@ -251,19 +326,96 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
251
326
  notify(path, 'user');
252
327
  }
253
328
 
254
- function addRow(node: SchemaNode): string {
329
+ function addRow(node: SchemaNode, block?: string): string {
255
330
  const key = newRowKey();
256
331
 
257
332
  if (!isPlainObject(get(node.path!))) {
258
333
  setPath(state, node.path!, {});
259
334
  }
260
335
 
261
- setPath(state, `${node.path}.${key}`, clone(node.props?.newRow ?? {}));
336
+ setPath(
337
+ state,
338
+ `${node.path}.${key}`,
339
+ clone(blockOf(node, block)?.newRow ?? node.props?.newRow ?? {}),
340
+ );
262
341
  touchRows(node);
263
342
 
264
343
  return key;
265
344
  }
266
345
 
346
+ /**
347
+ * Fit a copied row and its nodes for life on their own: stored files go, and
348
+ * nested rows get new keys, because theirs name records of the original.
349
+ */
350
+ function resetCopiedRow(copy: Record<string, any>, nodes: SchemaNode[], rowPath: string): void {
351
+ const relative = (path: string) => path.slice(rowPath.length + 1);
352
+
353
+ for (const node of nodes) {
354
+ if (node.acceptsUploads && node.path) {
355
+ setPath(copy, relative(node.path), null);
356
+ }
357
+
358
+ if (node.containsRows && node.path) {
359
+ const inner = rowsOf(node);
360
+ const fresh: Record<string, unknown> = {};
361
+
362
+ for (const [key, row] of Object.entries(getPath(copy, relative(node.path)) ?? {})) {
363
+ const freshKey = newRowKey();
364
+ const freshPath = `${node.path}.${freshKey}`;
365
+
366
+ inner[freshKey] = replaceRow(
367
+ inner[key] ?? [],
368
+ freshPath,
369
+ `${node.path}.${key}`,
370
+ );
371
+ resetCopiedRow(row as Record<string, any>, inner[freshKey], freshPath);
372
+ fresh[freshKey] = row;
373
+ }
374
+
375
+ setPath(copy, relative(node.path), fresh);
376
+ }
377
+
378
+ resetCopiedRow(copy, node.children ?? [], rowPath);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Add a copy of a row right after it, and return the copy's key. Its nodes come
384
+ * from the original, not the template, which has no options loaded for a row.
385
+ */
386
+ function cloneRow(node: SchemaNode, key: string): string {
387
+ const copyKey = newRowKey();
388
+ const copyPath = `${node.path}.${copyKey}`;
389
+ const rows = get(node.path!) ?? {};
390
+ const copy = clone(rows[key]);
391
+ const copyNodes = replaceRow(rowsOf(node)[key] ?? [], copyPath, `${node.path}.${key}`);
392
+
393
+ resetCopiedRow(copy, copyNodes, copyPath);
394
+
395
+ const reordered: Record<string, unknown> = {};
396
+
397
+ for (const [rowKey, row] of Object.entries(rows)) {
398
+ reordered[rowKey] = row;
399
+
400
+ if (rowKey === key) {
401
+ reordered[copyKey] = copy;
402
+ }
403
+ }
404
+
405
+ setPath(state, node.path!, reordered);
406
+ rowsOf(node)[copyKey] = copyNodes;
407
+ index(copyNodes);
408
+ touchRows(node);
409
+
410
+ const rowNodes = rowsOf(node);
411
+
412
+ node.props!.rows = Object.fromEntries(
413
+ Object.keys(reordered).map((rowKey) => [rowKey, rowNodes[rowKey]]),
414
+ );
415
+
416
+ return copyKey;
417
+ }
418
+
267
419
  function removeRow(node: SchemaNode, key: string): void {
268
420
  const prefix = `${node.path}.${key}`;
269
421
 
@@ -289,6 +441,10 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
289
441
  const reordered: Record<string, unknown> = {};
290
442
  keys.forEach((rowKey) => (reordered[rowKey] = rows[rowKey]));
291
443
  setPath(state, node.path!, reordered);
444
+
445
+ const rowNodes = rowsOf(node);
446
+
447
+ node.props!.rows = Object.fromEntries(keys.map((rowKey) => [rowKey, rowNodes[rowKey]]));
292
448
  touchRows(node);
293
449
  }
294
450
 
@@ -311,7 +467,10 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
311
467
  }
312
468
 
313
469
  for (const [path, messages] of Object.entries(patch.errors ?? {})) {
314
- errors[path] = Array.isArray(messages) ? messages[0] : messages;
470
+ // The user has already changed this value, so the message is about an old one.
471
+ if ((editVersions.get(path) ?? 0) === (versions.get(path) ?? 0)) {
472
+ errors[path] = Array.isArray(messages) ? messages[0] : messages;
473
+ }
315
474
  }
316
475
  }
317
476
 
@@ -350,28 +509,71 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
350
509
  },
351
510
  onError: (error) => {
352
511
  if (error instanceof HttpError && error.status === 419) {
353
- fatalError.value = 'This form has expired. Reload the page to continue.';
512
+ fatalError.value = t('This form has expired. Reload the page to continue.');
354
513
  } else {
355
- liveError.value =
356
- 'Some fields could not be updated. Check your connection and try again.';
514
+ liveError.value = t(
515
+ 'Some fields could not be updated. Check your connection and try again.',
516
+ );
357
517
  }
358
518
 
359
519
  console.error('[xelqun] Live update failed.', error);
360
520
  },
361
521
  });
362
522
 
523
+ const indicatorPaths = ref<string[]>([]);
524
+ let indicatorTimer: ReturnType<typeof setTimeout> | null = null;
525
+
526
+ function clearIndicatorTimer(): void {
527
+ if (indicatorTimer !== null) {
528
+ clearTimeout(indicatorTimer);
529
+ indicatorTimer = null;
530
+ }
531
+ }
532
+
533
+ scope.run(() => {
534
+ watch(
535
+ queue.loadingPaths,
536
+ (paths) => {
537
+ if (paths.length === 0) {
538
+ clearIndicatorTimer();
539
+ indicatorPaths.value = [];
540
+ } else if (indicatorPaths.value.length > 0) {
541
+ indicatorPaths.value = paths;
542
+ } else if (indicatorTimer === null) {
543
+ indicatorTimer = setTimeout(() => {
544
+ indicatorTimer = null;
545
+ indicatorPaths.value = queue.loadingPaths.value;
546
+ }, LOADING_INDICATOR_DELAY);
547
+ }
548
+ },
549
+ { flush: 'sync' },
550
+ );
551
+ });
552
+
553
+ function dependsOnAny(node: SchemaNode, paths: string[]): boolean {
554
+ return !!node.deps?.some((dependency) => paths.some((path) => overlaps(dependency, path)));
555
+ }
556
+
363
557
  /**
364
558
  * True while a request that can change this node is in flight.
365
559
  */
366
560
  function isLoading(node: SchemaNode): boolean {
367
- const paths = queue.loadingPaths.value;
561
+ return dependsOnAny(node, queue.loadingPaths.value);
562
+ }
368
563
 
369
- return !!node.deps?.some((dependency) => paths.some((path) => overlaps(dependency, path)));
564
+ /**
565
+ * True once that request has run long enough to deserve a spinner.
566
+ */
567
+ function showsLoadingIndicator(node: SchemaNode): boolean {
568
+ return dependsOnAny(node, indicatorPaths.value);
370
569
  }
371
570
 
372
571
  function setErrors(incoming: Record<string, string | string[]>): void {
373
572
  clearErrors();
573
+ addErrors(incoming);
574
+ }
374
575
 
576
+ function addErrors(incoming: Record<string, string | string[]>): void {
375
577
  for (const [path, messages] of Object.entries(incoming ?? {})) {
376
578
  errors[path] = Array.isArray(messages) ? messages[0] : messages;
377
579
  }
@@ -381,6 +583,253 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
381
583
  Object.keys(errors).forEach((path) => delete errors[path]);
382
584
  }
383
585
 
586
+ /**
587
+ * Wait until the state is what the user meant to send: nothing uploading,
588
+ * and no live update on its way.
589
+ */
590
+ async function settled(): Promise<void> {
591
+ await Promise.allSettled(uploads);
592
+ await queue.idle();
593
+ }
594
+
595
+ /**
596
+ * Run the rules the browser knows, and show what they find.
597
+ */
598
+ function validate(): boolean {
599
+ const found = findClientErrors(components);
600
+
601
+ if (Object.keys(found).length === 0) {
602
+ return true;
603
+ }
604
+
605
+ setErrors(found);
606
+ focusFirstError();
607
+
608
+ return false;
609
+ }
610
+
611
+ /**
612
+ * Forget the errors of these fields and the rows inside them, and no others.
613
+ */
614
+ function clearErrorsIn(paths: string[]): void {
615
+ Object.keys(errors)
616
+ .filter((path) => paths.some((scope) => startsWithPath(path, scope)))
617
+ .forEach((path) => delete errors[path]);
618
+ }
619
+
620
+ /**
621
+ * Run the rules the server sent for the browser, the way the server would:
622
+ * nothing under a hidden node, and nothing on a field the user can't change.
623
+ */
624
+ function findClientErrors(
625
+ list: SchemaNode[],
626
+ rowLabel: string | null = null,
627
+ found: Record<string, string> = {},
628
+ ): Record<string, string> {
629
+ for (const node of list) {
630
+ if (!isVisible(node)) {
631
+ continue;
632
+ }
633
+
634
+ const checked = node.path && node.validation && !node.computed && !node.readOnly;
635
+
636
+ if (checked && !isDisabled(node)) {
637
+ const message = firstFailure(node.validation!, get(node.path!), isRequired(node));
638
+
639
+ if (message) {
640
+ const name = node.validation!.attribute;
641
+ const attribute = rowLabel ? `${name} (${rowLabel})` : name;
642
+
643
+ found[node.path!] = translate(undefined, message, {
644
+ attribute,
645
+ Attribute: attribute.charAt(0).toUpperCase() + attribute.slice(1),
646
+ ATTRIBUTE: attribute.toUpperCase(),
647
+ });
648
+ }
649
+ }
650
+
651
+ findClientErrors(node.children ?? [], rowLabel, found);
652
+
653
+ if (node.containsRows) {
654
+ rowKeys(node).forEach((key, index) => {
655
+ const label = `${node.props?.itemLabel} ${index + 1}`;
656
+
657
+ findClientErrors(
658
+ rowsOf(node)[key] ?? [],
659
+ rowLabel ? `${rowLabel}, ${label}` : label,
660
+ found,
661
+ );
662
+ });
663
+ }
664
+ }
665
+
666
+ return found;
667
+ }
668
+
669
+ /**
670
+ * Show why a request failed: on the fields a validation error names, in
671
+ * the target form, or as a message on this one.
672
+ */
673
+ function reportFailure(
674
+ error: unknown,
675
+ target: Omit<ActionModal, 'state' | 'settled' | 'validate'> = {
676
+ addErrors,
677
+ focusFirstError,
678
+ fatalError,
679
+ liveError,
680
+ },
681
+ ): void {
682
+ if (error instanceof HttpError && error.status === 422) {
683
+ target.addErrors(error.validationErrors);
684
+ target.focusFirstError();
685
+ } else if (error instanceof HttpError && error.status === 419) {
686
+ target.fatalError.value = t('This form has expired. Reload the page to continue.');
687
+ } else {
688
+ target.liveError.value = t(
689
+ 'Some fields could not be updated. Check your connection and try again.',
690
+ );
691
+ }
692
+ }
693
+
694
+ function actionBody(
695
+ node: SchemaNode,
696
+ phase: ActionRequest['phase'],
697
+ data?: Record<string, any>,
698
+ ) {
699
+ return {
700
+ token: schema.token ?? '',
701
+ action: node.key,
702
+ phase,
703
+ state: { data: clone(state.data) },
704
+ data,
705
+ deps: dependencyMap(),
706
+ } satisfies ActionRequest;
707
+ }
708
+
709
+ /**
710
+ * Ask the server for the form of an action's modal. Null means it failed,
711
+ * and the failure is already on screen.
712
+ */
713
+ async function mountAction(node: SchemaNode): Promise<FormSchema | null> {
714
+ await settled();
715
+
716
+ try {
717
+ const response = await requests.track(
718
+ 'action',
719
+ [],
720
+ `${node.props?.label} (open)`,
721
+ postJson<{ form: FormSchema | null }>(
722
+ schema.endpoints.action,
723
+ actionBody(node, 'mount'),
724
+ ),
725
+ );
726
+
727
+ return response.form;
728
+ } catch (error) {
729
+ reportFailure(error);
730
+
731
+ return null;
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Run an action and apply what it changed. Null means it didn't go through,
737
+ * so a modal knows to stay open and show its errors.
738
+ */
739
+ async function runAction(node: SchemaNode, modal?: ActionModal): Promise<ActionResult | null> {
740
+ await settled();
741
+ await modal?.settled();
742
+
743
+ if (modal && !modal.validate()) {
744
+ return null;
745
+ }
746
+
747
+ const versions = new Map(editVersions);
748
+
749
+ liveError.value = null;
750
+
751
+ if (modal) {
752
+ modal.liveError.value = null;
753
+ }
754
+
755
+ try {
756
+ const response = await requests.track(
757
+ 'action',
758
+ [],
759
+ node.props?.label ?? null,
760
+ postJson<ActionResult>(
761
+ schema.endpoints.action,
762
+ actionBody(node, 'run', modal ? clone(modal.state.data) : undefined),
763
+ ),
764
+ );
765
+
766
+ applyPatch(response, versions);
767
+
768
+ return response;
769
+ } catch (error) {
770
+ reportFailure(error, modal);
771
+
772
+ return null;
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Check one step, in the browser and then on the server, and say whether it
778
+ * passed. Its errors are replaced, and the rest of the form's stay.
779
+ */
780
+ async function validateStep(step: SchemaNode): Promise<boolean> {
781
+ const paths: string[] = [];
782
+
783
+ eachNode([step], (node) => node.path && paths.push(node.path));
784
+ validating.value = true;
785
+ liveError.value = null;
786
+
787
+ try {
788
+ await settled();
789
+
790
+ const found = findClientErrors([step]);
791
+
792
+ clearErrorsIn(paths);
793
+
794
+ if (Object.keys(found).length > 0) {
795
+ Object.assign(errors, found);
796
+ focusFirstError();
797
+
798
+ return false;
799
+ }
800
+
801
+ const versions = new Map(editVersions);
802
+ const body: ValidateRequest = {
803
+ token: schema.token ?? '',
804
+ step: step.key,
805
+ state: { data: clone(state.data) },
806
+ deps: dependencyMap(),
807
+ };
808
+ const patch = await requests.track(
809
+ 'validate',
810
+ paths,
811
+ step.label ?? null,
812
+ postJson<UpdatePatch>(schema.endpoints.validate, body),
813
+ );
814
+
815
+ applyPatch(patch, versions);
816
+
817
+ const failed = Object.keys(patch.errors ?? {}).length > 0;
818
+
819
+ if (failed) {
820
+ focusFirstError();
821
+ }
822
+
823
+ return !failed;
824
+ } catch (error) {
825
+ reportFailure(error);
826
+
827
+ return false;
828
+ } finally {
829
+ validating.value = false;
830
+ }
831
+ }
832
+
384
833
  /**
385
834
  * Ask the UI to reveal and focus the first field with an error, in schema order.
386
835
  */
@@ -410,11 +859,15 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
410
859
  const element = root.value?.querySelector<HTMLElement>(
411
860
  `[data-xq-path="${CSS.escape(path)}"]`,
412
861
  );
413
- const target = element?.querySelector<HTMLElement>(
414
- 'input:not([type=hidden]), textarea, button, [tabindex]:not([tabindex="-1"])',
415
- );
416
-
417
- if (target) {
862
+ // An editor's text comes after its toolbar, and the text is what needs fixing.
863
+ const target =
864
+ element?.querySelector<HTMLElement>('[contenteditable="true"]') ??
865
+ element?.querySelector<HTMLElement>(
866
+ 'input:not([type=hidden]), textarea, button, [tabindex]:not([tabindex="-1"])',
867
+ );
868
+
869
+ // A closed tab stays mounted, and its panel opens a tick after the tab switches.
870
+ if (target && !target.closest('[hidden]')) {
418
871
  element!.scrollIntoView?.({
419
872
  block: 'center',
420
873
  behavior: prefersReducedMotion() ? 'auto' : 'smooth',
@@ -434,8 +887,15 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
434
887
  let failedValidation = false;
435
888
 
436
889
  processing.value = true;
437
- await Promise.allSettled(uploads);
438
- await queue.idle();
890
+ await settled();
891
+
892
+ if (!validate()) {
893
+ processing.value = false;
894
+ submitOptions.onError?.({ ...errors });
895
+ submitOptions.onFinish?.();
896
+
897
+ return;
898
+ }
439
899
 
440
900
  router.visit(url, {
441
901
  method: submitOptions.method ?? 'post',
@@ -450,6 +910,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
450
910
  },
451
911
  onSuccess: (page) => {
452
912
  clearErrors();
913
+ markClean();
453
914
  submitOptions.onSuccess?.(page);
454
915
  },
455
916
  onFinish: () => {
@@ -497,24 +958,36 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
497
958
  file.name,
498
959
  uploadFile(
499
960
  schema.endpoints.upload,
500
- { token: schema.token ?? '', path, file },
961
+ { token: schema.token ?? '', path, file, state: JSON.stringify(state.data) },
501
962
  onProgress,
502
963
  signal,
503
964
  ),
504
965
  signal,
505
966
  );
506
- const tracked = request.finally(() => {
507
- uploads.delete(tracked);
508
- uploading.value = uploads.size;
509
- });
510
967
 
511
- uploads.add(tracked);
512
- uploading.value = uploads.size;
968
+ trackUpload(request);
513
969
 
514
970
  return request;
515
971
  }
516
972
 
973
+ /**
974
+ * Make submitting wait for this, as well as for uploads already running. A
975
+ * field with files still queued has no request for them yet.
976
+ */
977
+ function trackUpload(work: Promise<unknown>): void {
978
+ const tracked = work
979
+ .catch(() => {})
980
+ .finally(() => {
981
+ uploads.delete(tracked);
982
+ uploading.value = uploads.size;
983
+ });
984
+
985
+ uploads.add(tracked);
986
+ uploading.value = uploads.size;
987
+ }
988
+
517
989
  function dispose(): void {
990
+ clearIndicatorTimer();
518
991
  scope.stop();
519
992
  effects.clear();
520
993
  }
@@ -523,6 +996,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
523
996
 
524
997
  return {
525
998
  id: schema.id,
999
+ locale: schema.locale,
526
1000
  schema,
527
1001
  components,
528
1002
  state,
@@ -536,25 +1010,41 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
536
1010
  root,
537
1011
  queue,
538
1012
  log: requests.entries,
1013
+ steppers,
1014
+ validating,
1015
+ t,
1016
+ markClean,
1017
+ isDirty,
539
1018
  get,
540
1019
  set,
541
1020
  blur,
542
1021
  isVisible,
543
1022
  isRequired,
544
1023
  isDisabled,
1024
+ text,
545
1025
  isLoading,
1026
+ showsLoadingIndicator,
546
1027
  evaluateCondition: (node: ConditionNode) => evaluateCondition(node, get),
547
1028
  rowKeys,
548
1029
  addRow,
1030
+ cloneRow,
549
1031
  removeRow,
550
1032
  moveRow,
551
1033
  applyPatch,
552
1034
  setErrors,
1035
+ addErrors,
553
1036
  clearErrors,
1037
+ validateStep,
554
1038
  focusFirstError,
1039
+ focusPath,
555
1040
  submit,
556
1041
  search,
557
1042
  upload,
1043
+ trackUpload,
558
1044
  dispose,
1045
+ settled,
1046
+ validate,
1047
+ mountAction,
1048
+ runAction,
559
1049
  };
560
1050
  }