@omnitend/dashboard-for-laravel 0.43.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/base/DFormSelect.vue.d.ts +1 -1
- package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
- package/dist/components/extended/DXForm.vue.d.ts +66 -1
- package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
- package/dist/components/extended/DXFormField.vue.d.ts +3 -0
- package/dist/components/extended/DXLoading.vue.d.ts +3 -0
- package/dist/components/extended/DXTable.vue.d.ts +20 -0
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
- package/dist/composables/useAsyncOptions.d.ts +14 -1
- package/dist/composables/useForm.d.ts +26 -0
- package/dist/dashboard-for-laravel.js +13605 -13058
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/utils/formErrorTargets.d.ts +86 -0
- package/docs/public/api-reference.json +148 -8
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +9 -8
- package/resources/css/theme.scss +176 -0
- package/resources/js/components/base/DFormSelect.vue +5 -2
- package/resources/js/components/extended/DXChoiceField.vue +42 -26
- package/resources/js/components/extended/DXField.vue +51 -17
- package/resources/js/components/extended/DXForm.vue +676 -28
- package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
- package/resources/js/components/extended/DXFormField.vue +11 -2
- package/resources/js/components/extended/DXLoading.vue +18 -11
- package/resources/js/components/extended/DXRepeater.vue +30 -4
- package/resources/js/components/extended/DXTable.vue +38 -9
- package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
- package/resources/js/composables/useAsyncOptions.ts +30 -5
- package/resources/js/composables/useForm.ts +196 -18
- package/resources/js/composables/useResourceEditor.ts +140 -107
- package/resources/js/index.ts +15 -0
- package/resources/js/types/index.ts +13 -0
- package/resources/js/utils/formErrorTargets.ts +317 -0
- 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
|
-
<!--
|
|
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.
|
|
31
|
-
|
|
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
|
|
56
|
+
<DTabs
|
|
57
|
+
:modelValue="activePaneId"
|
|
58
|
+
:card="tabsInCard"
|
|
59
|
+
:noKeyNav="true"
|
|
60
|
+
@update:modelValue="ignoreTabsWrite"
|
|
61
|
+
>
|
|
42
62
|
<DTab
|
|
43
|
-
v-for="
|
|
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
|
-
:
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
// —————————————————
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
//
|
|
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
|
|
503
|
-
(
|
|
504
|
-
if (
|
|
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({
|
|
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>
|