@omnitend/dashboard-for-laravel 0.42.2 → 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.
- package/dist/components/base/DFormSelect.vue.d.ts +1 -1
- package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
- package/dist/components/extended/DXCurrencyInput.vue.d.ts +2 -2
- package/dist/components/extended/DXDashboard.vue.d.ts +14 -6
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +12 -3
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +21 -1
- 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/DXStackingTable.vue.d.ts +25 -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 +12002 -11223
- 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 +7 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/types/navigation.d.ts +8 -0
- package/dist/utils/formErrorTargets.d.ts +86 -0
- package/dist/utils/pageScrollLock.d.ts +9 -0
- package/docs/public/api-reference.json +223 -9
- package/docs/public/docs-map.md +4 -2
- package/docs/public/llms.txt +4 -2
- package/package.json +9 -8
- package/resources/css/theme.scss +508 -36
- package/resources/js/components/base/DFormSelect.vue +5 -2
- package/resources/js/components/base/DModal.vue +20 -2
- package/resources/js/components/extended/DXChoiceField.vue +42 -26
- package/resources/js/components/extended/DXCurrencyInput.vue +3 -1
- package/resources/js/components/extended/DXDashboard.vue +236 -43
- package/resources/js/components/extended/DXDashboardNavbar.vue +23 -13
- package/resources/js/components/extended/DXDashboardSidebar.vue +103 -29
- package/resources/js/components/extended/DXField.vue +15 -9
- 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/DXRepeater.vue +30 -4
- package/resources/js/components/extended/DXStackingTable.vue +264 -0
- package/resources/js/components/extended/DXTable.vue +36 -4
- package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
- package/resources/js/composables/useAsyncOptions.ts +30 -5
- package/resources/js/composables/useContainerWidth.ts +12 -3
- package/resources/js/composables/useForm.ts +196 -18
- package/resources/js/composables/useResourceEditor.ts +140 -107
- package/resources/js/index.ts +17 -0
- package/resources/js/types/index.ts +13 -0
- package/resources/js/types/navigation.ts +9 -0
- package/resources/js/utils/formErrorTargets.ts +317 -0
- package/resources/js/utils/pageScrollLock.ts +27 -0
- package/scripts/mcp-server.mjs +58 -6
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component DXFormErrorSummary
|
|
3
|
+
Lists everything the last failed submit of a `useForm` returned: a heading,
|
|
4
|
+
then one row per validation message with a readable label ("Price (line 1):
|
|
5
|
+
Must be positive."). The heading is `title` ("Couldn't save. Please
|
|
6
|
+
check:") when the failure has field errors, and the server's message when
|
|
7
|
+
it has none (a message-only 422, a 500, a network failure), since then the
|
|
8
|
+
message is all there is. Built from `form.submitFailure`, so it survives
|
|
9
|
+
field edits and `clearError`, and disappears when the next submit starts or
|
|
10
|
+
one succeeds. Renders nothing before a failure. A row whose key belongs to a
|
|
11
|
+
visible field is a button that emits `select-target`, for the host to select
|
|
12
|
+
the field's tab and focus it; a key no visible field owns is listed as plain
|
|
13
|
+
text, after the owned rows, under `otherTitle` ("Other problems") when both
|
|
14
|
+
kinds are present. Place it next to the submit control.
|
|
15
|
+
-->
|
|
16
|
+
<template>
|
|
17
|
+
<!-- A real element owns the class: DAlert's root is a transition, so a
|
|
18
|
+
class on it is not a reliable host for the theme rules. BAlert
|
|
19
|
+
supplies role="alert" + aria-live="assertive"; keying it by the
|
|
20
|
+
failure count remounts it, so every new failure is announced once. -->
|
|
21
|
+
<div v-if="failure" class="dx-form-error-summary">
|
|
22
|
+
<DAlert :key="resolvedForm.failedSubmitCount" variant="danger">
|
|
23
|
+
<p class="dx-form-error-summary__message">{{ heading }}</p>
|
|
24
|
+
<!-- Rows a field owns (buttons that go to it), then rows no field
|
|
25
|
+
owns (text). The sub-heading only says "these are the rest"
|
|
26
|
+
when there is a first list for them to be the rest of. A
|
|
27
|
+
<p>, not a heading element: it must not enter the page
|
|
28
|
+
outline. -->
|
|
29
|
+
<template v-for="group in groups" :key="group.key">
|
|
30
|
+
<p
|
|
31
|
+
v-if="group.title !== null"
|
|
32
|
+
class="dx-form-error-summary__other-title"
|
|
33
|
+
>
|
|
34
|
+
{{ group.title }}
|
|
35
|
+
</p>
|
|
36
|
+
<ul class="dx-form-error-summary__list">
|
|
37
|
+
<li
|
|
38
|
+
v-for="row in group.rows"
|
|
39
|
+
:key="row.id"
|
|
40
|
+
class="dx-form-error-summary__item"
|
|
41
|
+
>
|
|
42
|
+
<button
|
|
43
|
+
v-if="row.fieldKey !== null"
|
|
44
|
+
type="button"
|
|
45
|
+
class="dx-form-error-summary__target"
|
|
46
|
+
:data-dx-error-key="row.errorKey"
|
|
47
|
+
@click="selectRow(row)"
|
|
48
|
+
>
|
|
49
|
+
{{ row.text }}
|
|
50
|
+
</button>
|
|
51
|
+
<span v-else :data-dx-error-key="row.errorKey">{{ row.text }}</span>
|
|
52
|
+
</li>
|
|
53
|
+
</ul>
|
|
54
|
+
</template>
|
|
55
|
+
</DAlert>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import { computed } from "vue";
|
|
61
|
+
import DAlert from "../base/DAlert.vue";
|
|
62
|
+
import type { UseFormReturn } from "../../composables/useForm";
|
|
63
|
+
import type { DefineFormReturn } from "../../composables/defineForm";
|
|
64
|
+
import type { FieldDefinition, FormTab } from "../../types";
|
|
65
|
+
import {
|
|
66
|
+
resolveErrorTargets,
|
|
67
|
+
type ErrorTarget,
|
|
68
|
+
} from "../../utils/formErrorTargets";
|
|
69
|
+
|
|
70
|
+
/** What a summary row points at (emitted by `select-target`). */
|
|
71
|
+
export interface ErrorSummarySelection {
|
|
72
|
+
/** The owning field's key. */
|
|
73
|
+
fieldKey: string;
|
|
74
|
+
/** The tab holding that field, or `null` in a flat form. */
|
|
75
|
+
tabKey: string | null;
|
|
76
|
+
/** The error key as the server sent it. */
|
|
77
|
+
errorKey: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Props {
|
|
81
|
+
/** The form: a `useForm` return or a `defineForm` return. */
|
|
82
|
+
form: UseFormReturn<any> | DefineFormReturn<any>;
|
|
83
|
+
/** Field definitions (optional when `form` is a defineForm return). */
|
|
84
|
+
fields?: FieldDefinition[];
|
|
85
|
+
/** The form's tabs, so rows follow tab order and know their tab. */
|
|
86
|
+
tabs?: FormTab[];
|
|
87
|
+
/**
|
|
88
|
+
* Extra context merged under the form data when evaluating `when` and
|
|
89
|
+
* function labels, as on DXForm.
|
|
90
|
+
*/
|
|
91
|
+
context?: Record<string, any>;
|
|
92
|
+
/**
|
|
93
|
+
* Field visibility, replacing the default (`when`/`show` against form
|
|
94
|
+
* data + `context`). A host form passes its own predicate so the summary
|
|
95
|
+
* agrees with what it renders.
|
|
96
|
+
*/
|
|
97
|
+
isFieldVisible?: (field: FieldDefinition) => boolean;
|
|
98
|
+
/** Tab visibility, replacing the default (the tab's `when`). */
|
|
99
|
+
isTabVisible?: (tab: FormTab) => boolean;
|
|
100
|
+
/**
|
|
101
|
+
* A field's label, replacing the default (its `label`, resolved against
|
|
102
|
+
* `labelModel`: the form model, or the row for a repeater sub-field).
|
|
103
|
+
*/
|
|
104
|
+
resolveLabel?: (
|
|
105
|
+
field: FieldDefinition,
|
|
106
|
+
labelModel: Record<string, any>,
|
|
107
|
+
) => string | null | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* Rows already resolved by the host (from `resolveErrorTargets` over
|
|
110
|
+
* `form.submitFailure.errors`). When given, the summary lists these and
|
|
111
|
+
* resolves nothing itself; the message still comes from
|
|
112
|
+
* `form.submitFailure`, and nothing renders while that is null.
|
|
113
|
+
*/
|
|
114
|
+
targets?: ErrorTarget[] | null;
|
|
115
|
+
/**
|
|
116
|
+
* The heading when the failure has at least one field error. The
|
|
117
|
+
* server's own message is not used then: Laravel's default 422 message
|
|
118
|
+
* is the first error plus "(and N more errors)", which repeats the first
|
|
119
|
+
* row. A failure with no field errors (a message-only 422, a 500, a
|
|
120
|
+
* network failure) keeps the server's message as the heading, as it is
|
|
121
|
+
* the only information.
|
|
122
|
+
*/
|
|
123
|
+
title?: string;
|
|
124
|
+
/**
|
|
125
|
+
* The sub-heading over the rows no visible field owns, listed after the
|
|
126
|
+
* owned rows. Shown only when there are both kinds; when no row is owned
|
|
127
|
+
* they are listed under the heading alone.
|
|
128
|
+
*/
|
|
129
|
+
otherTitle?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
133
|
+
title: "Couldn't save. Please check:",
|
|
134
|
+
otherTitle: "Other problems",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const emit = defineEmits<{
|
|
138
|
+
/** A row was clicked: select `tabKey` (if any) and focus `fieldKey`. */
|
|
139
|
+
"select-target": [selection: ErrorSummarySelection];
|
|
140
|
+
}>();
|
|
141
|
+
|
|
142
|
+
function isDefineForm(value: Props["form"]): value is DefineFormReturn<any> {
|
|
143
|
+
return (
|
|
144
|
+
!!value &&
|
|
145
|
+
typeof value === "object" &&
|
|
146
|
+
"form" in value &&
|
|
147
|
+
"fields" in value &&
|
|
148
|
+
!("data" in value)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const resolvedForm = computed<UseFormReturn<any>>(() =>
|
|
153
|
+
isDefineForm(props.form) ? props.form.form : props.form,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const resolvedFields = computed<FieldDefinition[]>(() => {
|
|
157
|
+
if (props.fields) return props.fields;
|
|
158
|
+
if (isDefineForm(props.form)) return props.form.fields;
|
|
159
|
+
return [];
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const failure = computed(() => resolvedForm.value.submitFailure ?? null);
|
|
163
|
+
|
|
164
|
+
const targets = computed<ErrorTarget[]>(() => {
|
|
165
|
+
if (!failure.value) return [];
|
|
166
|
+
if (props.targets) return props.targets;
|
|
167
|
+
return resolveErrorTargets(failure.value.errors, {
|
|
168
|
+
fields: resolvedFields.value,
|
|
169
|
+
tabs: props.tabs,
|
|
170
|
+
model: { ...(props.context ?? {}), ...resolvedForm.value.data },
|
|
171
|
+
isFieldVisible: props.isFieldVisible,
|
|
172
|
+
isTabVisible: props.isTabVisible,
|
|
173
|
+
resolveLabel: props.resolveLabel,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
interface Row {
|
|
178
|
+
id: string;
|
|
179
|
+
text: string;
|
|
180
|
+
errorKey: string;
|
|
181
|
+
fieldKey: string | null;
|
|
182
|
+
tabKey: string | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const rows = computed<Row[]>(() =>
|
|
186
|
+
targets.value.flatMap((target) =>
|
|
187
|
+
target.lines.map((text, index) => ({
|
|
188
|
+
id: `${target.errorKey}#${index}`,
|
|
189
|
+
text,
|
|
190
|
+
errorKey: target.errorKey,
|
|
191
|
+
fieldKey: target.fieldKey,
|
|
192
|
+
tabKey: target.tabKey,
|
|
193
|
+
})),
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const heading = computed(() =>
|
|
198
|
+
rows.value.length > 0 ? props.title : (failure.value?.message ?? ""),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
interface RowGroup {
|
|
202
|
+
key: "owned" | "other";
|
|
203
|
+
/** The sub-heading above the list, or `null` for none. */
|
|
204
|
+
title: string | null;
|
|
205
|
+
rows: Row[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Owned rows, then unowned ones (labelled only when both are present). */
|
|
209
|
+
const groups = computed<RowGroup[]>(() => {
|
|
210
|
+
const owned = rows.value.filter((row) => row.fieldKey !== null);
|
|
211
|
+
const other = rows.value.filter((row) => row.fieldKey === null);
|
|
212
|
+
const result: RowGroup[] = [];
|
|
213
|
+
if (owned.length > 0) result.push({ key: "owned", title: null, rows: owned });
|
|
214
|
+
if (other.length > 0) {
|
|
215
|
+
result.push({
|
|
216
|
+
key: "other",
|
|
217
|
+
title: owned.length > 0 ? props.otherTitle : null,
|
|
218
|
+
rows: other,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function selectRow(row: Row): void {
|
|
225
|
+
if (row.fieldKey === null) return;
|
|
226
|
+
emit("select-target", {
|
|
227
|
+
fieldKey: row.fieldKey,
|
|
228
|
+
tabKey: row.tabKey,
|
|
229
|
+
errorKey: row.errorKey,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
</script>
|
|
@@ -17,12 +17,18 @@
|
|
|
17
17
|
-->
|
|
18
18
|
<template>
|
|
19
19
|
<!-- Full replacement slot bypasses DXField entirely (mirrors tab-content):
|
|
20
|
-
also supersedes field-before/field-after for the same key.
|
|
20
|
+
also supersedes field-before/field-after for the same key. Rendered
|
|
21
|
+
as-is, with no wrapper (#194): `targetAttrs` is the marker DXForm
|
|
22
|
+
looks for to scroll to and focus a field after a failed submit, and
|
|
23
|
+
the consumer opts in by binding it (`v-bind="targetAttrs"`) on the
|
|
24
|
+
element holding the control. DXForm looks the marker up when it
|
|
25
|
+
needs it (a summary click, auto-scroll), never ahead of time. -->
|
|
21
26
|
<slot
|
|
22
27
|
v-if="$slots[`field(${field.key})`]"
|
|
23
28
|
:name="`field(${field.key})`"
|
|
24
29
|
:field="field"
|
|
25
30
|
:model="model"
|
|
31
|
+
:targetAttrs="targetAttrs"
|
|
26
32
|
/>
|
|
27
33
|
<template v-else>
|
|
28
34
|
<!-- Content inserted directly above the field. -->
|
|
@@ -52,7 +58,7 @@
|
|
|
52
58
|
</template>
|
|
53
59
|
|
|
54
60
|
<script setup lang="ts">
|
|
55
|
-
import { useSlots } from "vue";
|
|
61
|
+
import { computed, useSlots } from "vue";
|
|
56
62
|
import DXField from "./DXField.vue";
|
|
57
63
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
58
64
|
import type { FieldDefinition, LabelCols } from "../../types";
|
|
@@ -73,6 +79,9 @@ interface Props {
|
|
|
73
79
|
const props = defineProps<Props>();
|
|
74
80
|
const slots = useSlots();
|
|
75
81
|
|
|
82
|
+
/** What a `field(<key>)` slot binds to opt in to scroll/focus (#194). */
|
|
83
|
+
const targetAttrs = computed(() => ({ "data-dx-field-key": props.field.key }));
|
|
84
|
+
|
|
76
85
|
/**
|
|
77
86
|
* Map a DXField slot name to this field's keyed parent slot, when present.
|
|
78
87
|
* A plain function (re-read each render) rather than a computed, so a slot the
|
|
@@ -150,6 +150,7 @@ import type { UseFormReturn } from "../../composables/useForm";
|
|
|
150
150
|
import type { FieldDefinition } from "../../types";
|
|
151
151
|
import { getByPath, setByPath } from "../../utils/objectPath";
|
|
152
152
|
import { resolveFieldDefault } from "../../utils/formSchema";
|
|
153
|
+
import { humaniseErrorKey } from "../../utils/formErrorTargets";
|
|
153
154
|
|
|
154
155
|
interface Props {
|
|
155
156
|
/** Form instance owning the repeater array */
|
|
@@ -177,12 +178,37 @@ const minItems = computed(() => props.field.minItems ?? 0);
|
|
|
177
178
|
const maxItems = computed(() => props.field.maxItems);
|
|
178
179
|
const softDeleteKey = computed(() => props.field.softDeleteKey);
|
|
179
180
|
|
|
180
|
-
/**
|
|
181
|
-
*
|
|
181
|
+
/**
|
|
182
|
+
* The model a column header's function label is called with. Row fields
|
|
183
|
+
* resolve their labels against the ROW (as does the error summary), so a
|
|
184
|
+
* header uses the first visible row, layered over the outer model so a label
|
|
185
|
+
* that reads outer context (a currency on the parent form) still works. With
|
|
186
|
+
* no rows it is the outer model alone.
|
|
187
|
+
*/
|
|
188
|
+
const headerModel = computed<Record<string, any>>(() => {
|
|
189
|
+
const firstRow = visibleRows.value[0]?.row;
|
|
190
|
+
return {
|
|
191
|
+
...(props.model ?? {}),
|
|
192
|
+
...(firstRow !== null && typeof firstRow === "object" ? firstRow : {}),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** Column header text for a sub-field. A label function that throws (a row
|
|
197
|
+
* property it expects is missing) or returns nothing falls back to the
|
|
198
|
+
* humanised key, the same fallback the error summary uses, so a header can
|
|
199
|
+
* never break the table. */
|
|
182
200
|
function headerLabel(subField: FieldDefinition): string {
|
|
183
201
|
const label = subField.label;
|
|
184
|
-
if (typeof label
|
|
185
|
-
|
|
202
|
+
if (typeof label !== "function") return label || subField.key;
|
|
203
|
+
let resolved: unknown;
|
|
204
|
+
try {
|
|
205
|
+
resolved = label(headerModel.value);
|
|
206
|
+
} catch {
|
|
207
|
+
resolved = undefined;
|
|
208
|
+
}
|
|
209
|
+
return typeof resolved === "string" && resolved.trim() !== ""
|
|
210
|
+
? resolved
|
|
211
|
+
: humaniseErrorKey(subField.key);
|
|
186
212
|
}
|
|
187
213
|
|
|
188
214
|
// ————————————————— table/cards responsive fallback (#68 follow-up)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
The wrapper is what gets measured, never the table: a table that is
|
|
4
|
+
overflowing is WIDER than the space it has, so measuring it would never
|
|
5
|
+
report "too narrow". The wrapper is also a horizontal scroll container
|
|
6
|
+
(like Bootstrap's `.table-responsive`, which it replaces), which makes its
|
|
7
|
+
own width the available width even inside a flex row.
|
|
8
|
+
-->
|
|
9
|
+
<div
|
|
10
|
+
ref="containerRef"
|
|
11
|
+
class="dx-stacking-table"
|
|
12
|
+
:class="{ 'dx-stacking-table--stacked': isStacked }"
|
|
13
|
+
>
|
|
14
|
+
<!--
|
|
15
|
+
@slot The consumer's own `<table>`, as the wrapper's direct child.
|
|
16
|
+
Receives `stacked` for the rare markup that must differ per layout.
|
|
17
|
+
-->
|
|
18
|
+
<slot :stacked="isStacked" />
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script setup lang="ts">
|
|
23
|
+
/**
|
|
24
|
+
* DXStackingTable — a hand-built `<table>` that turns into one card per body
|
|
25
|
+
* row when the space it has (not the window) falls below `stackBelow`.
|
|
26
|
+
*
|
|
27
|
+
* Built for editable tables inside forms and modals (an order's lines: name,
|
|
28
|
+
* price input, quantity input, total), where inputs run off a phone's edge. The
|
|
29
|
+
* consumer keeps writing ordinary table markup; this wrapper measures itself
|
|
30
|
+
* with `useContainerWidth`, toggles `.dx-stacking-table--stacked`, and gives
|
|
31
|
+
* every body and footer cell its column's header text as a label (written to
|
|
32
|
+
* `data-dx-stack-label`, shown by theme.scss as the cell's `::before`). The
|
|
33
|
+
* stacked styling is global theme CSS, because slot content cannot see a
|
|
34
|
+
* component's scoped styles.
|
|
35
|
+
*
|
|
36
|
+
* Per-cell controls (all inert in the wide layout):
|
|
37
|
+
* - `data-label="…"` on a `th` or a cell overrides the derived label.
|
|
38
|
+
* - `.dx-stack-span` on a cell: no label, full card width (a card title).
|
|
39
|
+
* Cells spanning several columns (`colspan`) get no label and span anyway.
|
|
40
|
+
* - `.dx-stack-hide` on a cell: not shown when stacked. Empty cells with no
|
|
41
|
+
* label (a footer's ` ` filler) are hidden automatically.
|
|
42
|
+
* - `.dx-stack-continue` on a `tr`: joins the previous row's card (a note row
|
|
43
|
+
* under a line) instead of starting a new one.
|
|
44
|
+
*/
|
|
45
|
+
import { computed, onBeforeUnmount, onMounted } from "vue";
|
|
46
|
+
import { useContainerWidth } from "../../composables/useContainerWidth";
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
/**
|
|
50
|
+
* Stack when the wrapper's content width is below this many px. 576 is
|
|
51
|
+
* Bootstrap's `sm` breakpoint, but measured on the container.
|
|
52
|
+
*/
|
|
53
|
+
stackBelow?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
57
|
+
stackBelow: 576,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Attribute the derived label is written to (so a consumer `data-label` is never overwritten). */
|
|
61
|
+
const DERIVED_LABEL_ATTRIBUTE = "data-dx-stack-label";
|
|
62
|
+
/** Marks an empty cell that has no label, which the stacked layout hides. */
|
|
63
|
+
const EMPTY_CELL_ATTRIBUTE = "data-dx-stack-empty";
|
|
64
|
+
/** Header content that is not the column's visible name. */
|
|
65
|
+
const HIDDEN_HEADER_CONTENT =
|
|
66
|
+
'.visually-hidden, .visually-hidden-focusable, [aria-hidden="true"]';
|
|
67
|
+
|
|
68
|
+
const { containerRef, isBelow } = useContainerWidth({
|
|
69
|
+
threshold: () => props.stackBelow,
|
|
70
|
+
// Same band as DXForm's auto layout, for the same reason: the stacked
|
|
71
|
+
// layout is TALLER, so inside an `overflow:auto` ancestor it can add a
|
|
72
|
+
// scrollbar and change its own container's width at the boundary. 24px is
|
|
73
|
+
// wider than any scrollbar, so a crossing holds until the space genuinely
|
|
74
|
+
// changes.
|
|
75
|
+
hysteresis: 24,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Stacked until measured, like DXForm's `layout="auto"` (which renders its
|
|
80
|
+
* vertical layout before the first measurement and under SSR): a stacked table
|
|
81
|
+
* is legible at any width, a wide one guessed wrongly overflows. In a browser
|
|
82
|
+
* the first ResizeObserver delivery lands before the first paint, so a wide
|
|
83
|
+
* container does not visibly flash the cards.
|
|
84
|
+
*/
|
|
85
|
+
const isStacked = computed<boolean>(() => isBelow.value);
|
|
86
|
+
|
|
87
|
+
/** The column's visible name: `data-label`, else its text minus hidden helpers. */
|
|
88
|
+
function headerCellLabel(headerCell: HTMLTableCellElement): string {
|
|
89
|
+
const explicitLabel = headerCell.getAttribute("data-label");
|
|
90
|
+
if (explicitLabel !== null) return explicitLabel.trim();
|
|
91
|
+
|
|
92
|
+
let text = "";
|
|
93
|
+
const walker = document.createTreeWalker(headerCell, NodeFilter.SHOW_TEXT);
|
|
94
|
+
let node = walker.nextNode();
|
|
95
|
+
while (node !== null) {
|
|
96
|
+
// Only hiding INSIDE the header cell counts; an ancestor of the whole
|
|
97
|
+
// table being aria-hidden says nothing about this column's name.
|
|
98
|
+
const hiddenWrapper =
|
|
99
|
+
node.parentElement?.closest(HIDDEN_HEADER_CONTENT) ?? null;
|
|
100
|
+
const isHidden =
|
|
101
|
+
hiddenWrapper !== null &&
|
|
102
|
+
hiddenWrapper !== headerCell &&
|
|
103
|
+
headerCell.contains(hiddenWrapper);
|
|
104
|
+
if (!isHidden) text += ` ${node.textContent ?? ""}`;
|
|
105
|
+
node = walker.nextNode();
|
|
106
|
+
}
|
|
107
|
+
return text.replace(/\s+/g, " ").trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The first column of every cell in a row group, accounting for cells from
|
|
112
|
+
* earlier rows that reach down into a row (`rowspan`) as well as `colspan`:
|
|
113
|
+
* the table's own grid, not each row's cell index.
|
|
114
|
+
*/
|
|
115
|
+
function cellStartColumns(
|
|
116
|
+
rows: HTMLCollectionOf<HTMLTableRowElement>,
|
|
117
|
+
): Map<HTMLTableCellElement, number> {
|
|
118
|
+
const startColumns = new Map<HTMLTableCellElement, number>();
|
|
119
|
+
/** Columns taken in each row by a cell starting in an earlier row. */
|
|
120
|
+
const occupiedByRow: Array<Set<number>> = Array.from(
|
|
121
|
+
{ length: rows.length },
|
|
122
|
+
() => new Set<number>(),
|
|
123
|
+
);
|
|
124
|
+
Array.from(rows).forEach((row, rowIndex) => {
|
|
125
|
+
const occupied = occupiedByRow[rowIndex];
|
|
126
|
+
let column = 0;
|
|
127
|
+
for (const cell of Array.from(row.cells)) {
|
|
128
|
+
while (occupied.has(column)) column++;
|
|
129
|
+
startColumns.set(cell, column);
|
|
130
|
+
// rowspan="0" reaches to the end of the row group.
|
|
131
|
+
const rowSpan =
|
|
132
|
+
cell.rowSpan === 0 ? rows.length - rowIndex : cell.rowSpan;
|
|
133
|
+
const lastRow = Math.min(rowIndex + rowSpan, rows.length);
|
|
134
|
+
for (let spannedRow = rowIndex; spannedRow < lastRow; spannedRow++) {
|
|
135
|
+
for (let offset = 0; offset < cell.colSpan; offset++) {
|
|
136
|
+
occupiedByRow[spannedRow].add(column + offset);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
column += cell.colSpan;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return startColumns;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One label per column: the LOWEST header cell over it, so a group heading
|
|
147
|
+
* ("Pricing" over Price and Quantity) gives way to the names beneath it, and a
|
|
148
|
+
* heading spanning every header row ("Product") names its own column.
|
|
149
|
+
*/
|
|
150
|
+
function columnLabels(table: HTMLTableElement): string[] {
|
|
151
|
+
const headerRows = table.tHead?.rows;
|
|
152
|
+
if (headerRows === undefined || headerRows.length === 0) return [];
|
|
153
|
+
const labels: string[] = [];
|
|
154
|
+
// Row order, so a lower row's cell overwrites the group heading above it.
|
|
155
|
+
for (const [headerCell, startColumn] of cellStartColumns(headerRows)) {
|
|
156
|
+
const label = headerCellLabel(headerCell);
|
|
157
|
+
for (let offset = 0; offset < headerCell.colSpan; offset++) {
|
|
158
|
+
labels[startColumn + offset] = label;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return labels;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function setAttributeIfChanged(
|
|
165
|
+
element: Element,
|
|
166
|
+
name: string,
|
|
167
|
+
value: string | null,
|
|
168
|
+
): void {
|
|
169
|
+
if (value === null) {
|
|
170
|
+
if (element.hasAttribute(name)) element.removeAttribute(name);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (element.getAttribute(name) !== value) element.setAttribute(name, value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isBlank(cell: HTMLTableCellElement): boolean {
|
|
177
|
+
// `trim()` also strips the ` ` a footer filler cell usually holds.
|
|
178
|
+
return (
|
|
179
|
+
cell.children.length === 0 && (cell.textContent ?? "").trim() === ""
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Label every body and footer cell from its column's header. */
|
|
184
|
+
function labelCells(): void {
|
|
185
|
+
const table = containerRef.value?.querySelector<HTMLTableElement>(
|
|
186
|
+
":scope > table",
|
|
187
|
+
);
|
|
188
|
+
if (table === null || table === undefined) return;
|
|
189
|
+
|
|
190
|
+
const labels = columnLabels(table);
|
|
191
|
+
const rowGroups: HTMLTableSectionElement[] = [...Array.from(table.tBodies)];
|
|
192
|
+
if (table.tFoot !== null) rowGroups.push(table.tFoot);
|
|
193
|
+
|
|
194
|
+
for (const rowGroup of rowGroups) {
|
|
195
|
+
for (const [cell, startColumn] of cellStartColumns(rowGroup.rows)) {
|
|
196
|
+
// A cell spanning several columns belongs to none of them.
|
|
197
|
+
const derivedLabel =
|
|
198
|
+
cell.colSpan === 1 ? labels[startColumn] ?? "" : "";
|
|
199
|
+
setAttributeIfChanged(
|
|
200
|
+
cell,
|
|
201
|
+
DERIVED_LABEL_ATTRIBUTE,
|
|
202
|
+
derivedLabel === "" ? null : derivedLabel,
|
|
203
|
+
);
|
|
204
|
+
const hasLabel =
|
|
205
|
+
derivedLabel !== "" || cell.hasAttribute("data-label");
|
|
206
|
+
setAttributeIfChanged(
|
|
207
|
+
cell,
|
|
208
|
+
EMPTY_CELL_ATTRIBUTE,
|
|
209
|
+
!hasLabel && isBlank(cell) ? "" : null,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Rows come and go (v-for), and header text can change: relabel on any change
|
|
216
|
+
// to the table's structure or text. Only the attributes labelling reads are
|
|
217
|
+
// watched, so our own `data-dx-stack-*` writes never re-trigger this.
|
|
218
|
+
let mutationObserver: MutationObserver | null = null;
|
|
219
|
+
|
|
220
|
+
/** Attributes whose change can alter a label or a cell's column. */
|
|
221
|
+
const STRUCTURE_ATTRIBUTES = ["data-label", "colspan", "rowspan"];
|
|
222
|
+
/**
|
|
223
|
+
* Attributes that hide header helper text (HIDDEN_HEADER_CONTENT). They count
|
|
224
|
+
* only inside a `thead`: a body cell's classes change all the time (a row's
|
|
225
|
+
* state, a validation style) and say nothing about labels.
|
|
226
|
+
*/
|
|
227
|
+
const HEADER_VISIBILITY_ATTRIBUTES = ["class", "aria-hidden"];
|
|
228
|
+
|
|
229
|
+
function changesLabels(mutation: MutationRecord): boolean {
|
|
230
|
+
if (mutation.type !== "attributes") return true;
|
|
231
|
+
if (STRUCTURE_ATTRIBUTES.includes(mutation.attributeName ?? "")) return true;
|
|
232
|
+
return (
|
|
233
|
+
mutation.target instanceof Element &&
|
|
234
|
+
mutation.target.closest("thead") !== null
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
onMounted(() => {
|
|
239
|
+
labelCells();
|
|
240
|
+
if (typeof MutationObserver === "undefined" || containerRef.value === null) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
mutationObserver = new MutationObserver((mutations) => {
|
|
244
|
+
if (mutations.some(changesLabels)) labelCells();
|
|
245
|
+
});
|
|
246
|
+
mutationObserver.observe(containerRef.value, {
|
|
247
|
+
childList: true,
|
|
248
|
+
subtree: true,
|
|
249
|
+
characterData: true,
|
|
250
|
+
attributes: true,
|
|
251
|
+
attributeFilter: [
|
|
252
|
+
...STRUCTURE_ATTRIBUTES,
|
|
253
|
+
...HEADER_VISIBILITY_ATTRIBUTES,
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
onBeforeUnmount(() => {
|
|
259
|
+
mutationObserver?.disconnect();
|
|
260
|
+
mutationObserver = null;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
defineExpose({ isStacked });
|
|
264
|
+
</script>
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
<DFormSelect
|
|
141
141
|
v-else-if="field.filter === 'select-native'"
|
|
142
142
|
:model-value="nativeSelectFilterValue(field)"
|
|
143
|
-
:options="getFieldFilterOptions(field)
|
|
143
|
+
:options="getFieldFilterOptions(field)"
|
|
144
144
|
size="sm"
|
|
145
145
|
@update:model-value="handleSelectFilterChange(field, $event)"
|
|
146
146
|
/>
|
|
@@ -292,6 +292,8 @@
|
|
|
292
292
|
:save-text="saveText"
|
|
293
293
|
:create-text="createText"
|
|
294
294
|
:delete-text="deleteText"
|
|
295
|
+
:error-summary-title="errorSummaryTitle"
|
|
296
|
+
:error-summary-other-title="errorSummaryOtherTitle"
|
|
295
297
|
@save="handleEditSave"
|
|
296
298
|
@cancel="handleEditCancel"
|
|
297
299
|
@delete="handleDelete"
|
|
@@ -709,6 +711,19 @@ export interface Props<TItem = any> {
|
|
|
709
711
|
/** Override the modal's Delete button label (default: "Delete {item}"). */
|
|
710
712
|
deleteText?: string;
|
|
711
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Heading of the modal's failed-save summary when the failure has field
|
|
716
|
+
* errors (default "Couldn't save. Please check:"). A failure with none
|
|
717
|
+
* is headed by the server's message instead.
|
|
718
|
+
*/
|
|
719
|
+
errorSummaryTitle?: string;
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Sub-heading over the summary rows no edit field owns (default "Other
|
|
723
|
+
* problems"), shown only when some rows ARE owned.
|
|
724
|
+
*/
|
|
725
|
+
errorSummaryOtherTitle?: string;
|
|
726
|
+
|
|
712
727
|
/** API endpoint pattern for updates (e.g., "/api/products/:id") */
|
|
713
728
|
editUrl?: string;
|
|
714
729
|
|
|
@@ -826,6 +841,16 @@ export interface Props<TItem = any> {
|
|
|
826
841
|
*/
|
|
827
842
|
rowClickable?: (item: TItem, index: number) => boolean;
|
|
828
843
|
|
|
844
|
+
/**
|
|
845
|
+
* Whether clicking a row opens the edit modal (when `editFields` are set).
|
|
846
|
+
* Defaults to `true`. Set `false` when `editFields` exist only for the
|
|
847
|
+
* create modal (`openCreate()` / the create button) and a row click does
|
|
848
|
+
* something else, such as opening the record's own page from `row-clicked`:
|
|
849
|
+
* the row then emits `row-clicked` without also opening the edit modal, and
|
|
850
|
+
* only a `row-clicked` listener makes rows look clickable (#200).
|
|
851
|
+
*/
|
|
852
|
+
editOnRowClick?: boolean;
|
|
853
|
+
|
|
829
854
|
/** Enable client-side filtering, sorting, and pagination on items array */
|
|
830
855
|
clientSide?: boolean;
|
|
831
856
|
|
|
@@ -886,6 +911,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
886
911
|
footClone: false,
|
|
887
912
|
showEmpty: true,
|
|
888
913
|
fixedLayout: false,
|
|
914
|
+
editOnRowClick: true,
|
|
889
915
|
});
|
|
890
916
|
|
|
891
917
|
const emit = defineEmits<{
|
|
@@ -1038,9 +1064,12 @@ const isControlled = {
|
|
|
1038
1064
|
* The cursor and hover affordance follow from that — DXTable already knows,
|
|
1039
1065
|
* so consumers shouldn't have to reach into `:deep(tbody tr)` to say it (#107).
|
|
1040
1066
|
* The listener case was the gap: only `editFields` used to count.
|
|
1067
|
+
* `editFields` count only while a row click opens them (`editOnRowClick`, #200).
|
|
1041
1068
|
*/
|
|
1042
1069
|
const rowsAreInteractive = computed(
|
|
1043
|
-
() =>
|
|
1070
|
+
() =>
|
|
1071
|
+
(props.editOnRowClick && (props.editFields?.length ?? 0) > 0) ||
|
|
1072
|
+
hasListener('rowClicked'),
|
|
1044
1073
|
);
|
|
1045
1074
|
|
|
1046
1075
|
/**
|
|
@@ -2451,7 +2480,8 @@ const getFieldSortState = (fieldKey: string) => {
|
|
|
2451
2480
|
};
|
|
2452
2481
|
|
|
2453
2482
|
// Handle row click. Emits `rowClicked` regardless (the seam consumers listen
|
|
2454
|
-
// on), then asks the editor to open — a no-op unless `editFields` are set
|
|
2483
|
+
// on), then asks the editor to open — a no-op unless `editFields` are set, and
|
|
2484
|
+
// skipped when the consumer turned edit-on-click off (#200).
|
|
2455
2485
|
const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
2456
2486
|
// A row the consumer marked non-actionable doesn't look clickable, so it
|
|
2457
2487
|
// must not BE clickable either — otherwise a click that looks dead quietly
|
|
@@ -2459,7 +2489,9 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
2459
2489
|
if (props.rowClickable && !props.rowClickable(item, index)) return;
|
|
2460
2490
|
|
|
2461
2491
|
emit('rowClicked', item, index, event);
|
|
2462
|
-
|
|
2492
|
+
if (props.editOnRowClick) {
|
|
2493
|
+
editor.openEdit(item);
|
|
2494
|
+
}
|
|
2463
2495
|
};
|
|
2464
2496
|
|
|
2465
2497
|
defineExpose({
|