@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.
Files changed (54) 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/DXCurrencyInput.vue.d.ts +2 -2
  4. package/dist/components/extended/DXDashboard.vue.d.ts +14 -6
  5. package/dist/components/extended/DXDashboardNavbar.vue.d.ts +12 -3
  6. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +21 -1
  7. package/dist/components/extended/DXForm.vue.d.ts +66 -1
  8. package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
  9. package/dist/components/extended/DXFormField.vue.d.ts +3 -0
  10. package/dist/components/extended/DXStackingTable.vue.d.ts +25 -0
  11. package/dist/components/extended/DXTable.vue.d.ts +20 -0
  12. package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
  13. package/dist/composables/useAsyncOptions.d.ts +14 -1
  14. package/dist/composables/useForm.d.ts +26 -0
  15. package/dist/dashboard-for-laravel.js +12002 -11223
  16. package/dist/dashboard-for-laravel.js.map +1 -1
  17. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  18. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/style.css +1 -1
  21. package/dist/types/index.d.ts +12 -0
  22. package/dist/types/navigation.d.ts +8 -0
  23. package/dist/utils/formErrorTargets.d.ts +86 -0
  24. package/dist/utils/pageScrollLock.d.ts +9 -0
  25. package/docs/public/api-reference.json +223 -9
  26. package/docs/public/docs-map.md +4 -2
  27. package/docs/public/llms.txt +4 -2
  28. package/package.json +9 -8
  29. package/resources/css/theme.scss +508 -36
  30. package/resources/js/components/base/DFormSelect.vue +5 -2
  31. package/resources/js/components/base/DModal.vue +20 -2
  32. package/resources/js/components/extended/DXChoiceField.vue +42 -26
  33. package/resources/js/components/extended/DXCurrencyInput.vue +3 -1
  34. package/resources/js/components/extended/DXDashboard.vue +236 -43
  35. package/resources/js/components/extended/DXDashboardNavbar.vue +23 -13
  36. package/resources/js/components/extended/DXDashboardSidebar.vue +103 -29
  37. package/resources/js/components/extended/DXField.vue +15 -9
  38. package/resources/js/components/extended/DXForm.vue +676 -28
  39. package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
  40. package/resources/js/components/extended/DXFormField.vue +11 -2
  41. package/resources/js/components/extended/DXRepeater.vue +30 -4
  42. package/resources/js/components/extended/DXStackingTable.vue +264 -0
  43. package/resources/js/components/extended/DXTable.vue +36 -4
  44. package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
  45. package/resources/js/composables/useAsyncOptions.ts +30 -5
  46. package/resources/js/composables/useContainerWidth.ts +12 -3
  47. package/resources/js/composables/useForm.ts +196 -18
  48. package/resources/js/composables/useResourceEditor.ts +140 -107
  49. package/resources/js/index.ts +17 -0
  50. package/resources/js/types/index.ts +13 -0
  51. package/resources/js/types/navigation.ts +9 -0
  52. package/resources/js/utils/formErrorTargets.ts +317 -0
  53. package/resources/js/utils/pageScrollLock.ts +27 -0
  54. 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
- /** Column header text for a sub-field. No specific row context exists for a
181
- * header, so a function-valued label resolves against the outer model. */
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 === "function") return label(props.model ?? {});
185
- return label || subField.key;
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 `&nbsp;` 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 `&nbsp;` 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) as unknown as Record<string, unknown>[]"
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
- () => (props.editFields?.length ?? 0) > 0 || hasListener('rowClicked'),
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
- editor.openEdit(item);
2492
+ if (props.editOnRowClick) {
2493
+ editor.openEdit(item);
2494
+ }
2463
2495
  };
2464
2496
 
2465
2497
  defineExpose({