@omnitend/dashboard-for-laravel 0.43.0 → 0.44.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 (38) hide show
  1. package/dist/components/base/DFormSelect.vue.d.ts +1 -1
  2. package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
  3. package/dist/components/extended/DXForm.vue.d.ts +66 -1
  4. package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
  5. package/dist/components/extended/DXFormField.vue.d.ts +3 -0
  6. package/dist/components/extended/DXTable.vue.d.ts +20 -0
  7. package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
  8. package/dist/composables/useAsyncOptions.d.ts +14 -1
  9. package/dist/composables/useForm.d.ts +26 -0
  10. package/dist/dashboard-for-laravel.js +13374 -12823
  11. package/dist/dashboard-for-laravel.js.map +1 -1
  12. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  13. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  14. package/dist/index.d.ts +5 -1
  15. package/dist/style.css +1 -1
  16. package/dist/types/index.d.ts +12 -0
  17. package/dist/utils/formErrorTargets.d.ts +86 -0
  18. package/docs/public/api-reference.json +146 -6
  19. package/docs/public/docs-map.md +3 -2
  20. package/docs/public/llms.txt +3 -2
  21. package/package.json +9 -8
  22. package/resources/css/theme.scss +56 -0
  23. package/resources/js/components/base/DFormSelect.vue +5 -2
  24. package/resources/js/components/extended/DXChoiceField.vue +42 -26
  25. package/resources/js/components/extended/DXField.vue +15 -9
  26. package/resources/js/components/extended/DXForm.vue +676 -28
  27. package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
  28. package/resources/js/components/extended/DXFormField.vue +11 -2
  29. package/resources/js/components/extended/DXRepeater.vue +30 -4
  30. package/resources/js/components/extended/DXTable.vue +36 -4
  31. package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
  32. package/resources/js/composables/useAsyncOptions.ts +30 -5
  33. package/resources/js/composables/useForm.ts +196 -18
  34. package/resources/js/composables/useResourceEditor.ts +140 -107
  35. package/resources/js/index.ts +15 -0
  36. package/resources/js/types/index.ts +13 -0
  37. package/resources/js/utils/formErrorTargets.ts +317 -0
  38. package/scripts/mcp-server.mjs +58 -6
@@ -17,9 +17,19 @@
17
17
  @submit.prevent="handleSubmit"
18
18
  :class="{ 'dx-form--horizontal': resolvedLayout === 'horizontal' }"
19
19
  >
20
- <!-- Form-level error message -->
20
+ <!-- What the last failed submit returned (#194), when the consumer
21
+ asked for it at the top rather than beside the submit button. -->
22
+ <DXFormErrorSummary
23
+ v-if="errorSummary === 'top'"
24
+ class="mb-3"
25
+ v-bind="summaryBindings"
26
+ @select-target="focusErrorTarget"
27
+ />
28
+
29
+ <!-- Form-level error message. Not while the summary shows a failure:
30
+ one alert, never two (the summary already carries the message). -->
21
31
  <DAlert
22
- v-if="resolvedForm.shouldShowMessage"
32
+ v-if="resolvedForm.shouldShowMessage && !isSummaryShowing"
23
33
  :model-value="resolvedForm.shouldShowMessage"
24
34
  variant="danger"
25
35
  class="mb-3"
@@ -27,8 +37,13 @@
27
37
  {{ resolvedForm.message }}
28
38
  </DAlert>
29
39
 
30
- <!-- Tabbed layout. BTabs exposes the active *index* via v-model:index
31
- (plain v-model is the active tab id), which is what we track.
40
+ <!-- Tabbed layout. DXForm owns the selection (`activeTabKey`); DTabs
41
+ only renders it. DTabs gets the active PANE ID, controlled: its
42
+ own writes back (`update:modelValue`) are ignored, because bvn
43
+ re-derives ids from its registered-tab list, which lags a render
44
+ behind ours. Clicks and keys reach DXForm through each tab's own
45
+ click handler and button attributes, by tab key, never by
46
+ index; `no-key-nav` hands the arrow keys to DXForm.
32
47
  `card` needs BOTH: DTabs' own `card` prop (adds `card-header`/
33
48
  `card-body` classes to its nav/content internally) AND an outer
34
49
  `.card` element wrapping it (BVN's `card` prop does not add the
@@ -38,13 +53,21 @@
38
53
  `.card-body` internally via its own `card` prop, so wrapping
39
54
  that in another `.card-body` would double up. -->
40
55
  <component :is="tabsInCard ? DCard : 'div'" v-if="hasTabs" v-bind="tabsInCard ? { noBody: true } : {}">
41
- <DTabs v-model:index="activeTab" :card="tabsInCard">
56
+ <DTabs
57
+ :modelValue="activePaneId"
58
+ :card="tabsInCard"
59
+ :noKeyNav="true"
60
+ @update:modelValue="ignoreTabsWrite"
61
+ >
42
62
  <DTab
43
- v-for="(tab, index) in visibleTabs"
63
+ v-for="tab in visibleTabs"
44
64
  :key="tab.key"
65
+ :id="paneIdFor(tab.key)"
66
+ :buttonId="buttonIdFor(tab.key)"
45
67
  :title="resolveTabLabel(tab)"
46
68
  :lazy="tab.lazy"
47
- :active="index === 0"
69
+ :titleLinkAttrs="tabButtonAttrs(tab.key)"
70
+ :onClick="tabControlsFor(tab.key).onClick"
48
71
  >
49
72
  <!--
50
73
  @slot Replaces the entire body of a tab, keyed by tab (slot name `tab-content(<tabKey>)`).
@@ -117,6 +140,14 @@
117
140
  </template>
118
141
  </DXFormField>
119
142
 
143
+ <!-- Failed-submit summary, directly above the submit button. -->
144
+ <DXFormErrorSummary
145
+ v-if="errorSummary === 'footer'"
146
+ class="mt-3"
147
+ v-bind="summaryBindings"
148
+ @select-target="focusErrorTarget"
149
+ />
150
+
120
151
  <!-- Submit button -->
121
152
  <DXSaveButton
122
153
  v-if="showSubmit"
@@ -145,6 +176,15 @@
145
176
  DTabs' own card-body padding belongs to its tab content, not to
146
177
  trailing form-level actions. -->
147
178
  <template v-else>
179
+ <!-- Failed-submit summary: below the tabs (never inside a pane,
180
+ which may be hidden), directly above the submit button. -->
181
+ <DXFormErrorSummary
182
+ v-if="errorSummary === 'footer'"
183
+ class="mt-3"
184
+ v-bind="summaryBindings"
185
+ @select-target="focusErrorTarget"
186
+ />
187
+
148
188
  <!-- Submit button -->
149
189
  <DXSaveButton
150
190
  v-if="showSubmit"
@@ -171,8 +211,10 @@
171
211
  <script setup lang="ts">
172
212
  import {
173
213
  computed,
214
+ nextTick,
174
215
  onBeforeUnmount,
175
216
  ref,
217
+ useId,
176
218
  watch,
177
219
  type ComponentPublicInstance,
178
220
  } from "vue";
@@ -183,7 +225,9 @@ import DTabs from "../base/DTabs.vue";
183
225
  import { BTab as DTab } from "bootstrap-vue-next"; // raw BTab: BTabs scans slot vnodes for it (#119)
184
226
  import DXFormField from "./DXFormField.vue";
185
227
  import DXSaveButton from "./DXSaveButton.vue";
186
- import type { UseFormReturn } from "../../composables/useForm";
228
+ import DXFormErrorSummary, { type ErrorSummarySelection } from "./DXFormErrorSummary.vue";
229
+ import { resolveErrorTargets, type ErrorTarget } from "../../utils/formErrorTargets";
230
+ import type { UseFormReturn, ValidationErrors } from "../../composables/useForm";
187
231
  import type { DefineFormReturn } from "../../composables/defineForm";
188
232
  import { useContainerWidth } from "../../composables/useContainerWidth";
189
233
  import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
@@ -296,6 +340,46 @@ interface Props {
296
340
  * `sm` up and stacks above the input below `sm`.
297
341
  */
298
342
  labelCols?: LabelCols;
343
+
344
+ /**
345
+ * Where to list what the last failed submit returned (`form.submitFailure`:
346
+ * the server's message and every validation message, with readable
347
+ * labels):
348
+ *
349
+ * - `"footer"` (default) — directly above the submit button, below any
350
+ * tabs, next to the control the user just pressed.
351
+ * - `"top"` — above the fields, where the form-level alert sits.
352
+ * - `"external"` — the host renders a `DXFormErrorSummary` of its own
353
+ * elsewhere (beside a submit button outside the form, as DXTable's
354
+ * modal does). DXForm renders neither a summary nor, while a failure
355
+ * is listed, the form-level alert.
356
+ * - `false` — no summary; the form-level alert behaves as before.
357
+ *
358
+ * While a summary shows a failure the form-level alert does not render,
359
+ * so a failure shows exactly one alert. Each row naming a visible field is
360
+ * a button that selects the field's tab and focuses it.
361
+ */
362
+ errorSummary?: "footer" | "top" | "external" | false;
363
+
364
+ /**
365
+ * The summary's heading when the failure has field errors (default
366
+ * "Couldn't save. Please check:"). A failure with none (a message-only
367
+ * 422, a 500) is headed by the server's message instead.
368
+ */
369
+ errorSummaryTitle?: string;
370
+
371
+ /**
372
+ * The summary's sub-heading over the rows no visible field owns (default
373
+ * "Other problems"), shown only when some rows ARE owned.
374
+ */
375
+ errorSummaryOtherTitle?: string;
376
+
377
+ /**
378
+ * After a failed submit, scroll the first field with an error into view
379
+ * (once its tab is selected and rendered). Never moves focus. On by
380
+ * default.
381
+ */
382
+ scrollToError?: boolean;
299
383
  }
300
384
 
301
385
  const props = withDefaults(defineProps<Props>(), {
@@ -309,6 +393,8 @@ const props = withDefaults(defineProps<Props>(), {
309
393
  cardTabs: true,
310
394
  layout: "vertical",
311
395
  layoutThreshold: 640,
396
+ errorSummary: "footer",
397
+ scrollToError: true,
312
398
  });
313
399
 
314
400
  const emit = defineEmits<{
@@ -318,7 +404,10 @@ const emit = defineEmits<{
318
404
 
319
405
  const slots = defineSlots<Record<string, (props: any) => any>>();
320
406
 
321
- /** v-model for the active tab index. */
407
+ /**
408
+ * v-model for the active tab, as an index into the VISIBLE tabs. It is a
409
+ * view of `activeTabKey` (below), which is what DXForm actually tracks.
410
+ */
322
411
  const activeTab = defineModel<number>("activeTab", { default: 0 });
323
412
 
324
413
  // ————————————————— container-driven layout (`layout: "auto"`)
@@ -472,7 +561,14 @@ const visibleFlatFields = computed<FieldDefinition[]>(() =>
472
561
  resolvedFields.value.filter(isFieldVisible),
473
562
  );
474
563
 
475
- // ————————————————— auto-switch to the first error tab
564
+ // ————————————————— active tab, tracked by key (#194)
565
+ //
566
+ // One owner: `activeTabKey`. The pane bvn shows (`activePaneId`) and the
567
+ // index the `activeTab` model reports are both derived from it, and every
568
+ // change goes through `commitTabKey`, the one writer. Tracking an index
569
+ // instead broke whenever the tab list changed under it: an earlier tab
570
+ // hiding or tabs reordering silently showed a different tab, and bvn (which
571
+ // tracks tabs by id) mapped indexes against a stale list.
476
572
 
477
573
  /** Keys that currently carry at least one validation error. */
478
574
  const erroredKeys = computed<string[]>(() =>
@@ -481,31 +577,576 @@ const erroredKeys = computed<string[]>(() =>
481
577
  ),
482
578
  );
483
579
 
484
- function goToErrorTab(): void {
485
- if (!hasTabs.value || erroredKeys.value.length === 0) return;
486
- const tabIndex = visibleTabs.value.findIndex((tab) =>
487
- tab.fieldKeys.some((key) =>
488
- erroredKeys.value.some(
489
- // Match exact keys and nested (repeater) error keys.
490
- (errorKey) => errorKey === key || errorKey.startsWith(`${key}.`),
491
- ),
492
- ),
580
+ const visibleTabKeys = computed<string[]>(() =>
581
+ visibleTabs.value.map((tab) => tab.key),
582
+ );
583
+
584
+ /**
585
+ * Where each error key belongs, decided by the one resolver the summary also
586
+ * uses (#194), under THIS form's visibility rules: a field hidden by `when`,
587
+ * or on a tab DXForm does not render, owns nothing. So the tab a failure
588
+ * selects, the field it scrolls to and the summary's rows always agree,
589
+ * including nested keys a field claims with `errorKeys`. A tab key with no
590
+ * field definition (consumer-rendered content) still owns its own key and
591
+ * keys nested under it.
592
+ */
593
+ function errorTargetsFor(errors: ValidationErrors | null | undefined): ErrorTarget[] {
594
+ return resolveErrorTargets(errors, {
595
+ fields: resolvedFields.value,
596
+ tabs: hasTabs.value ? props.tabs : undefined,
597
+ model: model.value,
598
+ isFieldVisible,
599
+ isTabVisible: (tab) => visibleTabKeys.value.includes(tab.key),
600
+ });
601
+ }
602
+
603
+ /** Targets for the errors on the form now (tab choice, scrolling). */
604
+ const liveErrorTargets = computed<ErrorTarget[]>(() =>
605
+ erroredKeys.value.length === 0 ? [] : errorTargetsFor(resolvedForm.value.errors),
606
+ );
607
+
608
+ /** Targets for what the last failed submit returned (the summary's rows). */
609
+ const summaryTargets = computed<ErrorTarget[]>(() => {
610
+ const failure = resolvedForm.value.submitFailure;
611
+ return failure ? errorTargetsFor(failure.errors) : [];
612
+ });
613
+
614
+ /** Key of the first visible tab owning a visible errored field, or null. */
615
+ function firstErrorTabKey(): string | null {
616
+ const owned = liveErrorTargets.value.find((target) => target.tabKey !== null);
617
+ return owned ? owned.tabKey : null;
618
+ }
619
+
620
+ function sameKeys(keys: readonly string[], otherKeys: readonly string[] | undefined): boolean {
621
+ return (
622
+ otherKeys !== undefined &&
623
+ keys.length === otherKeys.length &&
624
+ keys.every((key, index) => key === otherKeys[index])
493
625
  );
494
- if (tabIndex !== -1) activeTab.value = tabIndex;
495
626
  }
496
627
 
497
- // Watch a primitive derived from the error keys so the effect reliably
498
- // re-runs when errors are set (deep-watching the stable reactive object
499
- // reference does not fire on key additions). `immediate` handles errors
500
- // already present on the form before mount.
628
+ /** The selected tab's key: the single source of truth for the active tab. */
629
+ const activeTabKey = ref<string | null>(null);
630
+
631
+ // Pane ids are opaque and stable per tab KEY: a number assigned the first
632
+ // time a key is seen, never derived from the key's text (which may hold
633
+ // spaces, invalid in an id) or from its position (which changes when tabs
634
+ // reorder or hide, and would make bvn show a different pane).
635
+ const tabIdBase = useId();
636
+ const paneNumbers = new Map<string, number>();
637
+ /** Monotonic: a pruned key's number is never handed out again. */
638
+ let nextPaneNumber = 0;
639
+
640
+ function paneIdFor(key: string): string {
641
+ let paneNumber = paneNumbers.get(key);
642
+ if (paneNumber === undefined) {
643
+ paneNumber = nextPaneNumber;
644
+ nextPaneNumber += 1;
645
+ paneNumbers.set(key, paneNumber);
646
+ }
647
+ return `${tabIdBase}-tab-${paneNumber}`;
648
+ }
649
+
650
+ function buttonIdFor(key: string): string {
651
+ return `${paneIdFor(key)}-button`;
652
+ }
653
+
654
+ /** The pane DTabs shows; undefined while the selected tab is not rendered. */
655
+ const activePaneId = computed<string | undefined>(() =>
656
+ activeTabKey.value !== null && visibleTabKeys.value.includes(activeTabKey.value)
657
+ ? paneIdFor(activeTabKey.value)
658
+ : undefined,
659
+ );
660
+
661
+ /** Invalidates a pending parent-answer check when a newer write happens. */
662
+ let modelWriteToken = 0;
663
+
664
+ /**
665
+ * The one writer of the active tab. Sets the key, then reports its visible
666
+ * index through the `activeTab` model when that differs from what the model
667
+ * holds (a new selection, or the same tab at a new position).
668
+ *
669
+ * A parent binding the model may reject or normalise the index it is sent.
670
+ * Once the flush has carried its answer back, the parent's value wins
671
+ * (controlled behaviour). With no binding the model updates locally, so the
672
+ * check finds the value it wrote and nothing changes.
673
+ */
674
+ function commitTabKey(key: string | null): void {
675
+ activeTabKey.value = key;
676
+ const index = key === null ? -1 : visibleTabKeys.value.indexOf(key);
677
+ if (index === -1 || index === activeTab.value) return;
678
+ activeTab.value = index;
679
+ const writeToken = ++modelWriteToken;
680
+ nextTick(() => {
681
+ if (writeToken !== modelWriteToken) return;
682
+ const answeredIndex = activeTab.value;
683
+ if (answeredIndex === index) return;
684
+ const answeredKey = visibleTabKeys.value[answeredIndex];
685
+ if (answeredKey !== undefined) commitTabKey(answeredKey);
686
+ });
687
+ }
688
+
689
+ /**
690
+ * Which tab to show. In order: after a failed submit (or with errors present
691
+ * at mount), the first visible tab owning a visible errored field; a tab
692
+ * index the parent just set; the current tab while it is still visible; the
693
+ * first visible tab. With no visible tabs the key is kept, so the same tab
694
+ * returns when they come back, and an index the parent sets meanwhile (or
695
+ * at mount) is held until they do: it cannot be resolved to a key yet.
696
+ */
697
+ let pendingRequestedIndex: number | null = null;
698
+
699
+ function decideTabKey(failed: boolean, incomingIndex: number | null): string | null {
700
+ const keys = visibleTabKeys.value;
701
+ if (keys.length === 0) {
702
+ if (incomingIndex !== null) pendingRequestedIndex = incomingIndex;
703
+ return activeTabKey.value;
704
+ }
705
+ const requestedIndex = incomingIndex ?? pendingRequestedIndex;
706
+ pendingRequestedIndex = null;
707
+ if (failed && props.autoErrorTab) {
708
+ const errorTabKey = firstErrorTabKey();
709
+ if (errorTabKey !== null) return errorTabKey;
710
+ }
711
+ if (requestedIndex !== null) {
712
+ const requestedKey = keys[requestedIndex];
713
+ if (requestedKey !== undefined) return requestedKey;
714
+ }
715
+ const currentKey = activeTabKey.value;
716
+ if (currentKey !== null && keys.includes(currentKey)) return currentKey;
717
+ return keys[0];
718
+ }
719
+
720
+ // "A submit failed": what sends the form to its error tab. Either signal
721
+ // counts:
722
+ //
723
+ // - `failedSubmitCount` changing. useForm counts every failed submit, so a
724
+ // `preserveErrors` resubmit that returns exactly the same set (its keys
725
+ // never leave the form) still takes the user back to the error.
726
+ // - the errored-key set GROWING. Errors set without a submit through useForm
727
+ // (`form.setErrors()` after a client-side check, or after a consumer's own
728
+ // request) keep selecting their tab, as they always have.
729
+ //
730
+ // An error CLEARING (a field edit) is neither, so fixing a field never moves
731
+ // the tab. The key comparison is between flushes, never inside one:
732
+ // `setErrors` empties the set and refills it synchronously, which a sync
733
+ // watcher would misread as an addition.
734
+ const failureCount = (): number => resolvedForm.value.failedSubmitCount ?? 0;
735
+
736
+ function keysGrew(keys: readonly string[], previousKeys: readonly string[] | undefined): boolean {
737
+ const previous = new Set(previousKeys ?? []);
738
+ return keys.some((key) => !previous.has(key));
739
+ }
740
+
741
+ // The decision runs ONCE per tick for every input that changed in it (the
742
+ // visible tabs, the failure signals, the model index), so two concerns can
743
+ // never write competing values in one flush.
744
+ let hasDecided = false;
501
745
  watch(
502
- () => erroredKeys.value.join("|"),
503
- (joined) => {
504
- if (props.autoErrorTab && joined) goToErrorTab();
746
+ [visibleTabKeys, () => erroredKeys.value, failureCount, () => activeTab.value] as const,
747
+ ([keys, errorKeys, count, index], previous) => {
748
+ if (!hasDecided) {
749
+ // Errors already on the form at mount select their tab.
750
+ hasDecided = true;
751
+ commitTabKey(decideTabKey(errorKeys.length > 0, index));
752
+ return;
753
+ }
754
+ const [previousKeys, previousErrorKeys, previousCount, previousIndex] = previous;
755
+ const submitFailed = count !== previousCount;
756
+ const failed = submitFailed || keysGrew(errorKeys, previousErrorKeys);
757
+ const requestedIndex = index !== previousIndex ? index : null;
758
+ if (!failed && requestedIndex === null && sameKeys(keys, previousKeys)) return;
759
+ commitTabKey(decideTabKey(failed, requestedIndex));
760
+ if (submitFailed && props.scrollToError) scrollToFirstError();
505
761
  },
506
762
  { immediate: true },
507
763
  );
508
764
 
765
+ /** A tab button the user clicked or reached with the keyboard. */
766
+ function selectTabFromUser(key: string): void {
767
+ if (visibleTabKeys.value.includes(key)) commitTabKey(key);
768
+ }
769
+
770
+ /**
771
+ * DTabs' own writes of the active pane id are ignored: they come from bvn
772
+ * re-deriving the selection from its registered-tab list, which can lag
773
+ * DXForm's by a render. The listener stays so the binding is controlled
774
+ * (without it bvn would overwrite the id locally).
775
+ */
776
+ function ignoreTabsWrite(): void {}
777
+
778
+ /**
779
+ * A tab's click handler and its nav button's attributes: a roving tabindex
780
+ * (only the selected tab is in the tab order) and the arrow-key handler,
781
+ * which DXForm owns because DTabs runs with `no-key-nav`.
782
+ *
783
+ * Cached per key (and selection state), so every value keeps its identity
784
+ * across renders. bvn re-evaluates this slot on every BTabs render and folds
785
+ * each tab's `onClick` and `titleLinkAttrs` into its tab list, so a fresh
786
+ * function or object each time re-renders BTabs, which re-evaluates the
787
+ * slot: an endless update loop ("Maximum recursive updates").
788
+ */
789
+ interface TabControls {
790
+ onClick: () => void;
791
+ selectedButtonAttrs: Record<string, unknown>;
792
+ unselectedButtonAttrs: Record<string, unknown>;
793
+ }
794
+
795
+ const tabControlsByKey = new Map<string, TabControls>();
796
+
797
+ function tabControlsFor(key: string): TabControls {
798
+ let controls = tabControlsByKey.get(key);
799
+ if (controls === undefined) {
800
+ const onKeydown = (event: KeyboardEvent) => handleTabKeydown(event, key);
801
+ controls = {
802
+ onClick: () => selectTabFromUser(key),
803
+ selectedButtonAttrs: { tabindex: 0, onKeydown },
804
+ unselectedButtonAttrs: { tabindex: -1, onKeydown },
805
+ };
806
+ tabControlsByKey.set(key, controls);
807
+ }
808
+ return controls;
809
+ }
810
+
811
+ // Forget the ids and controls of keys no longer in `props.tabs`, so a form
812
+ // whose tab list is rebuilt over its life does not keep every key it ever
813
+ // had. A tab hidden by `when` is still in `props.tabs` and keeps its id, so
814
+ // it returns as the same pane.
815
+ watch(
816
+ () => (props.tabs ?? []).map((tab) => tab.key),
817
+ (keys) => {
818
+ const current = new Set(keys);
819
+ for (const key of [...paneNumbers.keys()]) {
820
+ if (!current.has(key)) paneNumbers.delete(key);
821
+ }
822
+ for (const key of [...tabControlsByKey.keys()]) {
823
+ if (!current.has(key)) tabControlsByKey.delete(key);
824
+ }
825
+ },
826
+ );
827
+
828
+ function tabButtonAttrs(key: string): Record<string, unknown> {
829
+ const controls = tabControlsFor(key);
830
+ return key === activeTabKey.value
831
+ ? controls.selectedButtonAttrs
832
+ : controls.unselectedButtonAttrs;
833
+ }
834
+
835
+ /** bvn's horizontal key map: ←/→ step (Shift: to the end), Home/End/PageUp/PageDown jump. */
836
+ function handleTabKeydown(event: KeyboardEvent, key: string): void {
837
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
838
+ const keys = visibleTabKeys.value;
839
+ const fromIndex = keys.indexOf(key);
840
+ if (fromIndex === -1) return;
841
+ const lastIndex = keys.length - 1;
842
+ let targetIndex: number;
843
+ switch (event.key) {
844
+ case "ArrowLeft":
845
+ targetIndex = event.shiftKey ? 0 : Math.max(fromIndex - 1, 0);
846
+ break;
847
+ case "ArrowRight":
848
+ targetIndex = event.shiftKey ? lastIndex : Math.min(fromIndex + 1, lastIndex);
849
+ break;
850
+ case "Home":
851
+ case "PageUp":
852
+ targetIndex = 0;
853
+ break;
854
+ case "End":
855
+ case "PageDown":
856
+ targetIndex = lastIndex;
857
+ break;
858
+ default:
859
+ return;
860
+ }
861
+ event.preventDefault();
862
+ selectTabFromUser(keys[targetIndex]);
863
+ // Focus follows the tab that ends up selected, after any parent answer
864
+ // (commitTabKey's check was queued first, so it has run by now).
865
+ nextTick(() => {
866
+ const selectedKey = activeTabKey.value;
867
+ if (selectedKey === null) return;
868
+ document.getElementById(buttonIdFor(selectedKey))?.focus();
869
+ });
870
+ }
871
+
872
+ /** Select the first visible tab owning a visible errored field, if any. */
873
+ function goToErrorTab(): void {
874
+ const errorTabKey = firstErrorTabKey();
875
+ if (errorTabKey !== null) commitTabKey(errorTabKey);
876
+ }
877
+
878
+ // ————————————————— taking the user to an errored field (#194)
879
+
880
+ /**
881
+ * Controls a user types into or picks from: what a focus request lands on.
882
+ * Looked for before anything else focusable, so a field whose label carries
883
+ * an info button ("More information", earlier in document order) focuses
884
+ * its input, not the button. Matching is only the first step: every
885
+ * candidate must also pass `canTakeFocus`.
886
+ */
887
+ const EDITABLE_CONTROL = [
888
+ 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"])',
889
+ "select",
890
+ "textarea",
891
+ '[contenteditable="true"]',
892
+ '[contenteditable=""]',
893
+ '[role="combobox"]',
894
+ '[role="textbox"]',
895
+ '[role="spinbutton"]',
896
+ '[role="listbox"]',
897
+ ].join(", ");
898
+
899
+ /** The fallback for a widget with no editable control (a button picker). */
900
+ const ANY_FOCUSABLE = ["button", "a[href]", "[tabindex]"].join(", ");
901
+
902
+ /** Elements the browser focuses without a `tabindex`. */
903
+ const NATIVELY_FOCUSABLE = "input, select, textarea, button, a[href]";
904
+
905
+ /**
906
+ * Frames to wait for a field's element: a lazy tab mounts its pane a render
907
+ * or two after it is selected, and a repeater (an async component) renders
908
+ * its rows after that. After this many, a nested key that never rendered an
909
+ * element of its own (a media map's `image_media.<uuid>`) settles for its
910
+ * owning field.
911
+ */
912
+ const FIELD_RENDER_ATTEMPTS = 10;
913
+
914
+ /**
915
+ * Further frames a FOCUS request waits for a control that can take focus
916
+ * (about a second): an async editor renders its wrapper before its control,
917
+ * and a widget may render its control disabled until its data loads.
918
+ */
919
+ const CONTROL_RENDER_ATTEMPTS = 60;
920
+
921
+ const nextFrame = () =>
922
+ new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
923
+
924
+ /**
925
+ * Whether `candidate` can take focus now: laid out, not disabled (itself or
926
+ * through a disabled fieldset), not `aria-disabled`, not inside an `inert`
927
+ * subtree, and focusable at all (a native control, contenteditable, or any
928
+ * `tabindex`; a `div role="combobox"` with none is not). A negative
929
+ * `tabindex` only takes an element out of the Tab order; it can still be
930
+ * focused, so an editor surface with `tabindex="-1"` qualifies.
931
+ */
932
+ function canTakeFocus(candidate: HTMLElement): boolean {
933
+ if (candidate.offsetParent === null) return false;
934
+ if (candidate.matches(":disabled")) return false;
935
+ if (candidate.closest("fieldset:disabled") !== null) return false;
936
+ if (candidate.getAttribute("aria-disabled") === "true") return false;
937
+ if (candidate.closest("[inert]") !== null) return false;
938
+ return (
939
+ candidate.matches(NATIVELY_FOCUSABLE) ||
940
+ candidate.isContentEditable ||
941
+ candidate.hasAttribute("tabindex")
942
+ );
943
+ }
944
+
945
+ /**
946
+ * True when an element belongs to THIS form rather than to a DXForm nested in
947
+ * one of its slots, whose fields can carry the same `data-dx-field-key`.
948
+ */
949
+ function belongsToThisForm(element: HTMLElement): boolean {
950
+ const root = resolveFormElement();
951
+ return root !== null && element.closest("form") === root;
952
+ }
953
+
954
+ /** The first element matching `selector` (itself or inside) that can take focus. */
955
+ function firstFocusable(element: HTMLElement, selector: string): HTMLElement | null {
956
+ if (element.matches(selector) && canTakeFocus(element) && belongsToThisForm(element)) {
957
+ return element;
958
+ }
959
+ for (const candidate of Array.from(element.querySelectorAll<HTMLElement>(selector))) {
960
+ if (canTakeFocus(candidate) && belongsToThisForm(candidate)) return candidate;
961
+ }
962
+ return null;
963
+ }
964
+
965
+ /**
966
+ * The first shown element marked with this data path, or null. Compared as
967
+ * an attribute value rather than built into a selector, so a key holding
968
+ * quotes, brackets, spaces or dots (`image_media.<uuid>`) needs no escaping.
969
+ * Nested markers for one key (a slot binding `targetAttrs` around a DXField)
970
+ * resolve to the outer one, whose subtree holds the inner one's control.
971
+ */
972
+ function shownFieldElement(path: string): HTMLElement | null {
973
+ const root = resolveFormElement();
974
+ if (root === null) return null;
975
+ for (const element of Array.from(root.querySelectorAll<HTMLElement>("[data-dx-field-key]"))) {
976
+ if (
977
+ element.getAttribute("data-dx-field-key") === path &&
978
+ element.offsetParent !== null &&
979
+ element.closest("form") === root
980
+ ) {
981
+ return element;
982
+ }
983
+ }
984
+ return null;
985
+ }
986
+
987
+ /**
988
+ * The element to take the user to for an error: the one rendered for the
989
+ * EXACT error path (`lines.1.price`, a repeater row's field) when there is
990
+ * one, else the owning field's (`lines`). DXField's roots carry
991
+ * `data-dx-field-key`, as does whatever a `field(<key>)` slot binds
992
+ * `targetAttrs` to. Null when neither renders (an unmarked slot, consumer
993
+ * content, a tab that cannot be shown).
994
+ */
995
+ async function renderedErrorElement(
996
+ errorKey: string,
997
+ fieldKey: string,
998
+ ): Promise<HTMLElement | null> {
999
+ for (let attempt = 0; attempt < FIELD_RENDER_ATTEMPTS; attempt += 1) {
1000
+ await nextTick();
1001
+ const exact = shownFieldElement(errorKey);
1002
+ if (exact !== null) return exact;
1003
+ if (attempt < FIELD_RENDER_ATTEMPTS - 1) await nextFrame();
1004
+ }
1005
+ return errorKey === fieldKey ? null : shownFieldElement(fieldKey);
1006
+ }
1007
+
1008
+ /**
1009
+ * Scroll a field into view. A field that fits in the viewport is scrolled
1010
+ * whole, so its label and the message under its input show too. One taller
1011
+ * than the viewport (a marked container with a long explanation above its
1012
+ * input) is scrolled by its first usable control, centred, since scrolling
1013
+ * by its own edge can leave the input below the fold: an editable control
1014
+ * when there is one, else anything focusable (a picker's button). Never
1015
+ * focuses.
1016
+ */
1017
+ function scrollFieldIntoView(element: HTMLElement): void {
1018
+ const control =
1019
+ firstFocusable(element, EDITABLE_CONTROL) ?? firstFocusable(element, ANY_FOCUSABLE);
1020
+ if (control !== null && element.getBoundingClientRect().height > window.innerHeight) {
1021
+ control.scrollIntoView({ block: "center" });
1022
+ return;
1023
+ }
1024
+ element.scrollIntoView({ block: "nearest" });
1025
+ }
1026
+
1027
+ /** Bumped per scroll/focus request, so only the latest one acts. */
1028
+ let revealToken = 0;
1029
+
1030
+ /**
1031
+ * After a failed submit: once the decision has selected the tab and the
1032
+ * failure's render has flushed (its pane mounted), scroll the first owned
1033
+ * field on that tab into view. Each target is looked up in the form's DOM
1034
+ * at that moment, in order, never from an earlier render's record: a target
1035
+ * whose marker never renders (an unmarked `field(<key>)` slot, consumer
1036
+ * content) is skipped after its render frames, for the next one. No focus,
1037
+ * so a phone does not raise its keyboard over the summary.
1038
+ */
1039
+ function scrollToFirstError(): void {
1040
+ const selectedKey = activeTabKey.value;
1041
+ const targets = liveErrorTargets.value.filter(
1042
+ (target) =>
1043
+ target.fieldKey !== null &&
1044
+ (target.tabKey === null || target.tabKey === selectedKey),
1045
+ );
1046
+ if (targets.length === 0) return;
1047
+ const token = ++revealToken;
1048
+ void (async () => {
1049
+ for (const target of targets) {
1050
+ // Its first lookup waits for the flush (nextTick), so a marker
1051
+ // rendered by the failure itself is found.
1052
+ const element = await renderedErrorElement(target.errorKey, target.fieldKey!);
1053
+ if (token !== revealToken) return;
1054
+ if (element !== null) {
1055
+ scrollFieldIntoView(element);
1056
+ return;
1057
+ }
1058
+ }
1059
+ })();
1060
+ }
1061
+
1062
+ /**
1063
+ * Where focus goes when the field cannot take it (nothing marked renders,
1064
+ * or nothing in it can be focused): the selected tab's button, with its pane
1065
+ * scrolled into view, so focus never drops to the body (a summary row of an
1066
+ * unopened lazy tab, once the pane replaces what was shown). A form with no
1067
+ * tabs leaves focus where it is (the summary row).
1068
+ */
1069
+ function focusSelectedTab(): void {
1070
+ const selectedKey = activeTabKey.value;
1071
+ if (selectedKey === null) return;
1072
+ document.getElementById(paneIdFor(selectedKey))?.scrollIntoView({ block: "nearest" });
1073
+ document.getElementById(buttonIdFor(selectedKey))?.focus();
1074
+ }
1075
+
1076
+ /**
1077
+ * Take the user to an error a summary lists: select its tab, wait for the
1078
+ * pane to render, look the field up in the form (its marker, anywhere
1079
+ * inside the form's element, a teleport target inside the form included),
1080
+ * scroll it into view and focus its control: an editable control that can
1081
+ * take focus, waiting (about a second) while none can, e.g. an async editor
1082
+ * still loading or a widget rendered disabled until its data arrives;
1083
+ * anything else focusable only as a fallback. A focus counts only once
1084
+ * `document.activeElement` is the control. The field is looked up again by
1085
+ * its key whenever its element leaves the DOM (a re-render replacing it),
1086
+ * and a moment with none at all is waited out. When no marker renders
1087
+ * within the field's render frames, or nothing in it takes focus, focus
1088
+ * moves to the selected tab's button. Exposed so a summary rendered OUTSIDE
1089
+ * the form (DXTable's modal footer) can drive it.
1090
+ */
1091
+ async function focusErrorTarget(target: ErrorSummarySelection): Promise<void> {
1092
+ if (target.tabKey !== null && visibleTabKeys.value.includes(target.tabKey)) {
1093
+ commitTabKey(target.tabKey);
1094
+ }
1095
+ const token = ++revealToken;
1096
+ let element = await renderedErrorElement(
1097
+ target.errorKey ?? target.fieldKey,
1098
+ target.fieldKey,
1099
+ );
1100
+ if (token !== revealToken) return;
1101
+ if (element === null) {
1102
+ focusSelectedTab();
1103
+ return;
1104
+ }
1105
+ const path = element.getAttribute("data-dx-field-key") ?? target.fieldKey;
1106
+ scrollFieldIntoView(element);
1107
+
1108
+ const tryFocus = (control: HTMLElement | null): boolean => {
1109
+ if (control === null) return false;
1110
+ control.focus({ preventScroll: true });
1111
+ if (document.activeElement !== control) return false;
1112
+ control.scrollIntoView({ block: "nearest" });
1113
+ return true;
1114
+ };
1115
+
1116
+ for (let attempt = 0; attempt <= CONTROL_RENDER_ATTEMPTS; attempt += 1) {
1117
+ if (element !== null && tryFocus(firstFocusable(element, EDITABLE_CONTROL))) return;
1118
+ if (attempt === CONTROL_RENDER_ATTEMPTS) break;
1119
+ await nextFrame();
1120
+ if (token !== revealToken) return;
1121
+ // A re-render may have replaced the field's element, or removed it
1122
+ // for a moment: look it up again by its key.
1123
+ if (element === null || !element.isConnected) element = shownFieldElement(path);
1124
+ }
1125
+ if (element !== null && tryFocus(firstFocusable(element, ANY_FOCUSABLE))) return;
1126
+ focusSelectedTab();
1127
+ }
1128
+
1129
+ // ————————————————— failed-submit summary
1130
+
1131
+ /** A summary is listing a failure, so the form-level alert steps aside. */
1132
+ const isSummaryShowing = computed(
1133
+ () =>
1134
+ props.errorSummary !== false &&
1135
+ resolvedForm.value.submitFailure !== null &&
1136
+ resolvedForm.value.submitFailure !== undefined,
1137
+ );
1138
+
1139
+ const summaryBindings = computed(() => ({
1140
+ form: resolvedForm.value,
1141
+ fields: resolvedFields.value,
1142
+ tabs: props.tabs,
1143
+ context: props.context,
1144
+ targets: summaryTargets.value,
1145
+ // Undefined when unset, so the summary's own defaults apply.
1146
+ title: props.errorSummaryTitle,
1147
+ otherTitle: props.errorSummaryOtherTitle,
1148
+ }));
1149
+
509
1150
  // ————————————————— saved state (submit button shows "✓ Saved")
510
1151
 
511
1152
  /**
@@ -585,5 +1226,12 @@ function handleSubmit(): void {
585
1226
  emit("submit");
586
1227
  }
587
1228
 
588
- defineExpose({ goToErrorTab });
1229
+ defineExpose({
1230
+ /** Select the first visible tab owning a visible errored field, if any. */
1231
+ goToErrorTab,
1232
+ /** Select an error's tab, then scroll to and focus its field. */
1233
+ focusErrorTarget,
1234
+ /** What the last failed submit returned, resolved by this form's rules. */
1235
+ errorTargets: summaryTargets,
1236
+ });
589
1237
  </script>