@omnitend/dashboard-for-laravel 0.16.3 → 0.17.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.
@@ -24,99 +24,197 @@
24
24
  </DAlert>
25
25
 
26
26
  <!-- Tabbed layout. BTabs exposes the active *index* via v-model:index
27
- (plain v-model is the active tab id), which is what we track. -->
28
- <DTabs v-if="hasTabs" v-model:index="activeTab">
29
- <DTab
30
- v-for="(tab, index) in visibleTabs"
31
- :key="tab.key"
32
- :title="resolveTabLabel(tab)"
33
- :lazy="tab.lazy"
34
- :active="index === 0"
35
- >
36
- <!--
37
- @slot Replaces the entire body of a tab, keyed by tab (slot name `tab-content(<tabKey>)`).
38
- @binding {FormTab} tab The tab definition being rendered.
39
- @binding {object} model Live form data merged with `context`, for predicates.
40
- -->
41
- <slot
42
- v-if="$slots[`tab-content(${tab.key})`]"
43
- :name="`tab-content(${tab.key})`"
44
- :tab="tab"
45
- :model="model"
46
- />
47
-
48
- <div v-else class="pt-3">
27
+ (plain v-model is the active tab id), which is what we track.
28
+ `card` needs BOTH: DTabs' own `card` prop (adds `card-header`/
29
+ `card-body` classes to its nav/content internally) AND an outer
30
+ `.card` element wrapping it (BVN's `card` prop does not add the
31
+ outer wrapper itself — the consumer supplies it, per BVN's docs
32
+ pattern). `no-body` stops DCard from adding its own
33
+ `.card-body` wrapper — DTabs already provides `.card-header`/
34
+ `.card-body` internally via its own `card` prop, so wrapping
35
+ that in another `.card-body` would double up. -->
36
+ <component :is="card ? DCard : 'div'" v-if="hasTabs" v-bind="card ? { noBody: true } : {}">
37
+ <DTabs v-model:index="activeTab" :card="card">
38
+ <DTab
39
+ v-for="(tab, index) in visibleTabs"
40
+ :key="tab.key"
41
+ :title="resolveTabLabel(tab)"
42
+ :lazy="tab.lazy"
43
+ :active="index === 0"
44
+ >
49
45
  <!--
50
- @slot Content inserted above a tab's fields, keyed by tab (slot name `tab-before(<tabKey>)`).
46
+ @slot Replaces the entire body of a tab, keyed by tab (slot name `tab-content(<tabKey>)`).
51
47
  @binding {FormTab} tab The tab definition being rendered.
52
48
  @binding {object} model Live form data merged with `context`, for predicates.
53
49
  -->
54
- <slot :name="`tab-before(${tab.key})`" :tab="tab" :model="model" />
55
-
56
- <DXField
57
- v-for="field in visibleFieldsFor(tab)"
58
- :key="field.key"
59
- :field="field"
60
- :form="resolvedForm"
50
+ <slot
51
+ v-if="$slots[`tab-content(${tab.key})`]"
52
+ :name="`tab-content(${tab.key})`"
53
+ :tab="tab"
61
54
  :model="model"
62
- >
63
- <template
64
- v-for="(slotName, target) in fieldSlotMap(field.key)"
65
- :key="target"
66
- #[target]="slotProps"
67
- >
68
- <!-- @slot Per-field overrides forwarded to DXField, keyed by field key: `value(<key>)`, `span(<key>)`, `info(<key>)`, `hint(<key>)`, `repeater-row(<key>)`. -->
69
- <slot :name="slotName" v-bind="slotProps" />
55
+ />
56
+
57
+ <div v-else class="pt-3">
58
+ <!--
59
+ @slot Content inserted above a tab's fields, keyed by tab (slot name `tab-before(<tabKey>)`).
60
+ @binding {FormTab} tab The tab definition being rendered.
61
+ @binding {object} model Live form data merged with `context`, for predicates.
62
+ -->
63
+ <slot :name="`tab-before(${tab.key})`" :tab="tab" :model="model" />
64
+
65
+ <template v-for="field in visibleFieldsFor(tab)" :key="field.key">
66
+ <!--
67
+ @slot Fully replaces a field's rendering (including its label), keyed by field key: `field(<key>)`. Unlike `value(<key>)`, bypasses DXField entirely — like `tab-content`, this also supersedes `field-before(<key>)`/`field-after(<key>)` for the same key.
68
+ @binding {FieldDefinition} field The field definition being replaced.
69
+ @binding {object} model Live form data merged with `context`, for predicates.
70
+ -->
71
+ <slot
72
+ v-if="$slots[`field(${field.key})`]"
73
+ :name="`field(${field.key})`"
74
+ :field="field"
75
+ :model="model"
76
+ />
77
+ <template v-else>
78
+ <!--
79
+ @slot Content inserted directly above a field, keyed by field key: `field-before(<key>)`. Not rendered when `field(<key>)` replaces the same field.
80
+ @binding {FieldDefinition} field The field about to be rendered.
81
+ @binding {object} model Live form data merged with `context`, for predicates.
82
+ -->
83
+ <slot :name="`field-before(${field.key})`" :field="field" :model="model" />
84
+
85
+ <DXField
86
+ :field="field"
87
+ :form="resolvedForm"
88
+ :model="model"
89
+ :layout="field.layout ?? layout"
90
+ :label-cols="field.labelCols ?? labelCols"
91
+ >
92
+ <template
93
+ v-for="(slotName, target) in fieldSlotMap(field.key)"
94
+ :key="target"
95
+ #[target]="slotProps"
96
+ >
97
+ <!-- @slot Per-field overrides forwarded to DXField, keyed by field key: `value(<key>)`, `span(<key>)`, `info(<key>)`, `hint(<key>)`, `repeater-row(<key>)`. -->
98
+ <slot :name="slotName" v-bind="slotProps" />
99
+ </template>
100
+ </DXField>
101
+
102
+ <!--
103
+ @slot Content inserted directly below a field, keyed by field key: `field-after(<key>)`. Not rendered when `field(<key>)` replaces the same field.
104
+ @binding {FieldDefinition} field The field that was just rendered.
105
+ @binding {object} model Live form data merged with `context`, for predicates.
106
+ -->
107
+ <slot :name="`field-after(${field.key})`" :field="field" :model="model" />
108
+ </template>
70
109
  </template>
71
- </DXField>
72
110
 
111
+ <!--
112
+ @slot Content inserted below a tab's fields, keyed by tab (slot name `tab-after(<tabKey>)`).
113
+ @binding {FormTab} tab The tab definition being rendered.
114
+ @binding {object} model Live form data merged with `context`, for predicates.
115
+ -->
116
+ <slot :name="`tab-after(${tab.key})`" :tab="tab" :model="model" />
117
+ </div>
118
+ </DTab>
119
+ </DTabs>
120
+ </component>
121
+
122
+ <!-- Flat layout (no tabs). When `card` is set, the fields + submit
123
+ button + footer render inside a DCard, giving the form a visual
124
+ boundary (the tabbed case gets this from DTabs' own `card` prop
125
+ instead, above). -->
126
+ <template v-if="!hasTabs">
127
+ <component :is="card ? DCard : 'div'">
128
+ <template v-for="field in visibleFlatFields" :key="field.key">
73
129
  <!--
74
- @slot Content inserted below a tab's fields, keyed by tab (slot name `tab-after(<tabKey>)`).
75
- @binding {FormTab} tab The tab definition being rendered.
130
+ @slot Fully replaces a field's rendering (including its label), keyed by field key: `field(<key>)`. Unlike `value(<key>)`, bypasses DXField entirely — like `tab-content`, this also supersedes `field-before(<key>)`/`field-after(<key>)` for the same key.
131
+ @binding {FieldDefinition} field The field definition being replaced.
76
132
  @binding {object} model Live form data merged with `context`, for predicates.
77
133
  -->
78
- <slot :name="`tab-after(${tab.key})`" :tab="tab" :model="model" />
79
- </div>
80
- </DTab>
81
- </DTabs>
134
+ <slot
135
+ v-if="$slots[`field(${field.key})`]"
136
+ :name="`field(${field.key})`"
137
+ :field="field"
138
+ :model="model"
139
+ />
140
+ <template v-else>
141
+ <!--
142
+ @slot Content inserted directly above a field, keyed by field key: `field-before(<key>)`. Not rendered when `field(<key>)` replaces the same field.
143
+ @binding {FieldDefinition} field The field about to be rendered.
144
+ @binding {object} model Live form data merged with `context`, for predicates.
145
+ -->
146
+ <slot :name="`field-before(${field.key})`" :field="field" :model="model" />
147
+
148
+ <DXField
149
+ :field="field"
150
+ :form="resolvedForm"
151
+ :model="model"
152
+ :layout="field.layout ?? layout"
153
+ :label-cols="field.labelCols ?? labelCols"
154
+ >
155
+ <template
156
+ v-for="(slotName, target) in fieldSlotMap(field.key)"
157
+ :key="target"
158
+ #[target]="slotProps"
159
+ >
160
+ <!-- @slot Per-field overrides forwarded to DXField, keyed by field key: `value(<key>)`, `span(<key>)`, `info(<key>)`, `hint(<key>)`, `repeater-row(<key>)`. -->
161
+ <slot :name="slotName" v-bind="slotProps" />
162
+ </template>
163
+ </DXField>
164
+
165
+ <!--
166
+ @slot Content inserted directly below a field, keyed by field key: `field-after(<key>)`. Not rendered when `field(<key>)` replaces the same field.
167
+ @binding {FieldDefinition} field The field that was just rendered.
168
+ @binding {object} model Live form data merged with `context`, for predicates.
169
+ -->
170
+ <slot :name="`field-after(${field.key})`" :field="field" :model="model" />
171
+ </template>
172
+ </template>
82
173
 
83
- <!-- Flat layout (no tabs) -->
84
- <template v-else>
85
- <DXField
86
- v-for="field in visibleFlatFields"
87
- :key="field.key"
88
- :field="field"
89
- :form="resolvedForm"
90
- :model="model"
91
- >
92
- <template
93
- v-for="(slotName, target) in fieldSlotMap(field.key)"
94
- :key="target"
95
- #[target]="slotProps"
174
+ <!-- Submit button -->
175
+ <DButton
176
+ v-if="showSubmit"
177
+ type="submit"
178
+ variant="primary"
179
+ :disabled="resolvedForm.processing"
180
+ class="w-100 mt-3"
96
181
  >
97
- <!-- @slot Per-field overrides forwarded to DXField, keyed by field key: `value(<key>)`, `span(<key>)`, `info(<key>)`, `hint(<key>)`, `repeater-row(<key>)`. -->
98
- <slot :name="slotName" v-bind="slotProps" />
99
- </template>
100
- </DXField>
182
+ <span v-if="resolvedForm.processing">{{ submitLoadingText }}</span>
183
+ <span v-else>{{ submitText }}</span>
184
+ </DButton>
185
+
186
+ <!--
187
+ @slot Content rendered below the submit button (e.g. a cancel link or secondary actions).
188
+ @binding {UseFormReturn} form The resolved form instance (state, errors, submit helpers).
189
+ -->
190
+ <slot name="footer" :form="resolvedForm" />
191
+ </component>
101
192
  </template>
102
193
 
103
- <!-- Submit button -->
104
- <DButton
105
- v-if="showSubmit"
106
- type="submit"
107
- variant="primary"
108
- :disabled="resolvedForm.processing"
109
- class="w-100 mt-3"
110
- >
111
- <span v-if="resolvedForm.processing">{{ submitLoadingText }}</span>
112
- <span v-else>{{ submitText }}</span>
113
- </DButton>
114
-
115
- <!--
116
- @slot Content rendered below the submit button (e.g. a cancel link or secondary actions).
117
- @binding {UseFormReturn} form The resolved form instance (state, errors, submit helpers).
118
- -->
119
- <slot name="footer" :form="resolvedForm" />
194
+ <!-- Tabbed layout: submit button + footer always render as siblings
195
+ below DTabs (outside the card, when `card` is set) — unlike the
196
+ flat layout, they aren't pulled inside the card here, since
197
+ DTabs' own card-body padding belongs to its tab content, not to
198
+ trailing form-level actions. -->
199
+ <template v-else>
200
+ <!-- Submit button -->
201
+ <DButton
202
+ v-if="showSubmit"
203
+ type="submit"
204
+ variant="primary"
205
+ :disabled="resolvedForm.processing"
206
+ class="w-100 mt-3"
207
+ >
208
+ <span v-if="resolvedForm.processing">{{ submitLoadingText }}</span>
209
+ <span v-else>{{ submitText }}</span>
210
+ </DButton>
211
+
212
+ <!--
213
+ @slot Content rendered below the submit button (e.g. a cancel link or secondary actions).
214
+ @binding {UseFormReturn} form The resolved form instance (state, errors, submit helpers).
215
+ -->
216
+ <slot name="footer" :form="resolvedForm" />
217
+ </template>
120
218
  </BForm>
121
219
  </template>
122
220
 
@@ -125,12 +223,13 @@ import { computed, watch } from "vue";
125
223
  import { BForm } from "bootstrap-vue-next";
126
224
  import DAlert from "../base/DAlert.vue";
127
225
  import DButton from "../base/DButton.vue";
226
+ import DCard from "../base/DCard.vue";
128
227
  import DTabs from "../base/DTabs.vue";
129
228
  import DTab from "../base/DTab.vue";
130
229
  import DXField from "./DXField.vue";
131
230
  import type { UseFormReturn } from "../../composables/useForm";
132
231
  import type { DefineFormReturn } from "../../composables/defineForm";
133
- import type { FieldDefinition, FormTab, MaybeFn } from "../../types";
232
+ import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
134
233
 
135
234
  interface Props {
136
235
  /**
@@ -164,6 +263,29 @@ interface Props {
164
263
 
165
264
  /** Auto-switch to the first tab containing a validation error. */
166
265
  autoErrorTab?: boolean;
266
+
267
+ /**
268
+ * Wrap the form in a card for a visual boundary (mirrors DXTable's
269
+ * `card` prop). Tabbed forms render the tab nav as a BS5
270
+ * card-header-tabs instead of double-wrapping. Off by default since
271
+ * DXForm is commonly embedded in a page card or modal already.
272
+ */
273
+ card?: boolean;
274
+
275
+ /**
276
+ * Form-wide field layout: "vertical" (default, label above input) or
277
+ * "horizontal" (label left, input right). Overridable per-field via
278
+ * `field.layout`. A field with `span: true` always renders full-width,
279
+ * regardless of layout.
280
+ */
281
+ layout?: "vertical" | "horizontal";
282
+
283
+ /**
284
+ * Label column width for horizontal layout (mirrors BFormGroup's
285
+ * `labelCols`/`labelCols*` props). Overridable per-field via
286
+ * `field.labelCols`. Ignored when `layout` is "vertical".
287
+ */
288
+ labelCols?: LabelCols;
167
289
  }
168
290
 
169
291
  const props = withDefaults(defineProps<Props>(), {
@@ -171,6 +293,8 @@ const props = withDefaults(defineProps<Props>(), {
171
293
  submitLoadingText: "Submitting...",
172
294
  showSubmit: true,
173
295
  autoErrorTab: true,
296
+ card: false,
297
+ layout: "vertical",
174
298
  });
175
299
 
176
300
  const emit = defineEmits<{
@@ -5,7 +5,157 @@
5
5
  field's `minItems`/`maxItems` limits.
6
6
  -->
7
7
  <template>
8
- <div class="dx-repeater">
8
+ <!-- `repeaterLayout: "table"` is a PREFERENCE, not a guarantee: the table
9
+ needs real horizontal room for its columns, and the same viewport
10
+ width can put this repeater in a wide standalone form or a narrow
11
+ sidebar column — a viewport breakpoint can't tell those apart. A
12
+ ResizeObserver on THIS OUTER wrapper measures the space actually
13
+ available and falls back to the cards layout when it's not enough,
14
+ scaling to this repeater's own column count (see
15
+ `neededTableWidth`/`showTable` below). This outer wrapper is never
16
+ hidden (an element measures 0×0 once `display:none`, which would
17
+ leave a hidden table with no way to detect regained space and
18
+ un-hide itself) — but its two children below ARE mutually exclusive
19
+ via `v-if`/`v-else`, not `v-show`: only ever ONE of table/cards is
20
+ actually mounted, so a field with side effects (e.g. an async
21
+ `optionsLoader`) doesn't run twice forever just because table mode
22
+ is configured. -->
23
+ <div v-if="wantsTableLayout" ref="containerRef" class="dx-repeater-container">
24
+ <div v-if="showTable" class="dx-repeater-table-wrapper">
25
+ <table class="dx-repeater-table table">
26
+ <thead>
27
+ <tr>
28
+ <th v-for="subField in subFields" :key="subField.key">
29
+ {{ headerLabel(subField) }}
30
+ </th>
31
+ <th class="dx-repeater-table-remove-col"></th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ <tr
36
+ v-for="(entry, position) in visibleRows"
37
+ :key="rowKey(entry.row, position)"
38
+ >
39
+ <!--
40
+ @slot Custom layout for a single repeater row, replacing the default sub-field stack (including the built-in Remove control — call `remove()` yourself). In table layout, rendered as the `<tr>`'s children: supply one `<td>` per sub-field plus a trailing `<td>` to match the header's column count (the header always includes an empty column for the built-in remove button).
41
+ @binding {object} row The current row's data object.
42
+ @binding {number} index The row's zero-based position among visible rows.
43
+ @binding {FieldDefinition[]} fields The sub-field definitions for the row.
44
+ @binding {function} remove Removes this row (respects `minItems`; soft-deletes instead of splicing when `field.softDeleteKey` is set and the row has an `id`).
45
+ @binding {string} path The dot path into `form.data` for this row (e.g. `lines.0`).
46
+ -->
47
+ <slot
48
+ v-if="$slots.row"
49
+ name="row"
50
+ :row="entry.row"
51
+ :index="position"
52
+ :fields="subFields"
53
+ :remove="() => removeRow(entry.index)"
54
+ :path="rowPath(entry.index)"
55
+ />
56
+
57
+ <template v-else>
58
+ <td v-for="subField in subFields" :key="subField.key">
59
+ <DXField
60
+ :field="subField"
61
+ :form="form"
62
+ :model="entry.row"
63
+ :key-path="`${rowPath(entry.index)}.${subField.key}`"
64
+ :error-key="`${rowPath(entry.index)}.${subField.key}`"
65
+ hide-label
66
+ />
67
+ </td>
68
+ <td class="dx-repeater-table-remove-col">
69
+ <DButton
70
+ variant="outline-danger"
71
+ class="dx-repeater-table-remove-btn"
72
+ :disabled="visibleRows.length <= minItems"
73
+ :aria-label="`Remove row ${position + 1}`"
74
+ @click="removeRow(entry.index)"
75
+ >
76
+ <i-lucide-trash-2 aria-hidden="true" />
77
+ </DButton>
78
+ </td>
79
+ </template>
80
+ </tr>
81
+ </tbody>
82
+ </table>
83
+
84
+ <DButton
85
+ variant="outline-primary"
86
+ size="sm"
87
+ :disabled="maxItems !== undefined && visibleRows.length >= maxItems"
88
+ @click="addRow"
89
+ >
90
+ {{ field.addLabel || "Add" }}
91
+ </DButton>
92
+ </div>
93
+
94
+ <!-- Cards fallback: identical to the default cards markup below,
95
+ shown instead of the table when there isn't enough room for it.
96
+ `v-else` (not `v-show`) — only the OUTER wrapper above needs to
97
+ stay mounted for measurement; unmounting whichever of these two
98
+ isn't current avoids permanently double-running every DXField
99
+ in both layouts at once (double async optionsLoader fetches,
100
+ double watchers, double everything else a field might do). -->
101
+ <div v-else class="dx-repeater">
102
+ <div
103
+ v-for="(entry, position) in visibleRows"
104
+ :key="rowKey(entry.row, position)"
105
+ class="dx-repeater-row"
106
+ >
107
+ <slot
108
+ v-if="$slots.row"
109
+ name="row"
110
+ :row="entry.row"
111
+ :index="position"
112
+ :fields="subFields"
113
+ :remove="() => removeRow(entry.index)"
114
+ :path="rowPath(entry.index)"
115
+ />
116
+
117
+ <template v-else>
118
+ <div class="dx-repeater-row-header">
119
+ <span v-if="field.showRowIndex" class="dx-repeater-row-index">{{ position + 1 }}</span>
120
+ <DButton
121
+ variant="outline-danger"
122
+ size="sm"
123
+ :disabled="visibleRows.length <= minItems"
124
+ @click="removeRow(entry.index)"
125
+ >
126
+ Remove
127
+ </DButton>
128
+ </div>
129
+ <DXField
130
+ v-for="subField in subFields"
131
+ :key="subField.key"
132
+ :field="subField"
133
+ :form="form"
134
+ :model="entry.row"
135
+ :key-path="`${rowPath(entry.index)}.${subField.key}`"
136
+ :error-key="`${rowPath(entry.index)}.${subField.key}`"
137
+ />
138
+ </template>
139
+ </div>
140
+
141
+ <DButton
142
+ variant="outline-primary"
143
+ size="sm"
144
+ :disabled="maxItems !== undefined && visibleRows.length >= maxItems"
145
+ @click="addRow"
146
+ >
147
+ {{ field.addLabel || "Add" }}
148
+ </DButton>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- Cards layout (default, `repeaterLayout: "cards"` or unset): each row
153
+ is its own bordered card with sub-fields stacked vertically. Kept as
154
+ a separate, unconditional branch (rather than reusing the fallback
155
+ above) so this — the overwhelmingly common case — never pays for a
156
+ ResizeObserver or the extra wrapper div; its markup is byte-for-byte
157
+ what cards mode has always rendered. -->
158
+ <div v-else class="dx-repeater">
9
159
  <div
10
160
  v-for="(entry, position) in visibleRows"
11
161
  :key="rowKey(entry.row, position)"
@@ -32,7 +182,7 @@
32
182
  <!-- Default row: stack each sub-field -->
33
183
  <template v-else>
34
184
  <div class="dx-repeater-row-header">
35
- <span class="dx-repeater-row-index">{{ position + 1 }}</span>
185
+ <span v-if="field.showRowIndex" class="dx-repeater-row-index">{{ position + 1 }}</span>
36
186
  <DButton
37
187
  variant="outline-danger"
38
188
  size="sm"
@@ -66,7 +216,7 @@
66
216
  </template>
67
217
 
68
218
  <script setup lang="ts">
69
- import { computed } from "vue";
219
+ import { computed, onBeforeUnmount, ref, watch } from "vue";
70
220
  import DButton from "../base/DButton.vue";
71
221
  import DXField from "./DXField.vue";
72
222
  import type { UseFormReturn } from "../../composables/useForm";
@@ -99,6 +249,79 @@ const minItems = computed(() => props.field.minItems ?? 0);
99
249
  const maxItems = computed(() => props.field.maxItems);
100
250
  const softDeleteKey = computed(() => props.field.softDeleteKey);
101
251
 
252
+ /** Column header text for a sub-field. No specific row context exists for a
253
+ * header, so a function-valued label resolves against the outer model. */
254
+ function headerLabel(subField: FieldDefinition): string {
255
+ const label = subField.label;
256
+ if (typeof label === "function") return label(props.model ?? {});
257
+ return label || subField.key;
258
+ }
259
+
260
+ // ————————————————— table/cards responsive fallback (#68 follow-up)
261
+
262
+ const wantsTableLayout = computed(() => props.field.repeaterLayout === "table");
263
+
264
+ const containerRef = ref<HTMLElement | null>(null);
265
+ const containerWidth = ref<number | null>(null);
266
+
267
+ // Heuristic minimum width for the table to stay legible: per-sub-field
268
+ // column widths (not a pixel-measured fit, just a reasonable estimate —
269
+ // `currency`/`percentage` get extra room for their `£`/`%` affix, which
270
+ // otherwise crowds out the number itself) plus the trailing icon-only
271
+ // remove column. Scales with this repeater's own column count/types,
272
+ // unlike a single fixed viewport breakpoint.
273
+ const DEFAULT_COLUMN_WIDTH = 130;
274
+ const AFFIX_COLUMN_WIDTH = 160;
275
+ const REMOVE_COLUMN_WIDTH = 70;
276
+
277
+ function columnWidthFor(subField: FieldDefinition): number {
278
+ if (subField.type === "currency" || subField.type === "percentage") {
279
+ return AFFIX_COLUMN_WIDTH;
280
+ }
281
+ return DEFAULT_COLUMN_WIDTH;
282
+ }
283
+
284
+ const neededTableWidth = computed(
285
+ () =>
286
+ subFields.value.reduce((total, subField) => total + columnWidthFor(subField), 0) +
287
+ REMOVE_COLUMN_WIDTH,
288
+ );
289
+
290
+ // `null` (not yet measured) defaults to showing the table — the configured
291
+ // preference — rather than flashing cards first. Resolves as soon as the
292
+ // watcher below fires (see its comment for why that isn't simply onMounted).
293
+ const showTable = computed(
294
+ () => containerWidth.value === null || containerWidth.value >= neededTableWidth.value,
295
+ );
296
+
297
+ let resizeObserver: ResizeObserver | null = null;
298
+
299
+ // A `watch` on the ref itself, not `onMounted`: DXRepeater is loaded via
300
+ // `defineAsyncComponent` (DXField imports it lazily to break a circular
301
+ // import), and for an async-resolved component whose root element also
302
+ // carries `v-if`, the template ref is not always populated yet by the time
303
+ // `onMounted` fires — observed live as `containerRef.value === null` inside
304
+ // `onMounted` despite the element existing in the DOM at that point. `watch`
305
+ // instead reacts whenever the ref actually receives (or loses, e.g. if
306
+ // `wantsTableLayout` ever toggles false) its element, regardless of when
307
+ // that happens relative to lifecycle hooks.
308
+ watch(containerRef, (el) => {
309
+ resizeObserver?.disconnect();
310
+ resizeObserver = null;
311
+ if (!el || typeof ResizeObserver === "undefined") return;
312
+ containerWidth.value = el.getBoundingClientRect().width;
313
+ resizeObserver = new ResizeObserver((entries) => {
314
+ const entry = entries[0];
315
+ if (entry) containerWidth.value = entry.contentRect.width;
316
+ });
317
+ resizeObserver.observe(el);
318
+ });
319
+
320
+ onBeforeUnmount(() => {
321
+ resizeObserver?.disconnect();
322
+ resizeObserver = null;
323
+ });
324
+
102
325
  function isSoftDeleted(row: any): boolean {
103
326
  return Boolean(
104
327
  softDeleteKey.value &&
@@ -254,12 +477,55 @@ function removeRow(index: number): void {
254
477
  .dx-repeater-row-header {
255
478
  display: flex;
256
479
  align-items: center;
257
- justify-content: space-between;
258
480
  margin-bottom: 0.75rem;
259
481
  }
260
482
 
483
+ /* The Remove control is always this header's last child, whether or not the
484
+ (opt-in) row-index span is also present — push it to the end explicitly
485
+ rather than relying on `justify-content: space-between`, which centres a
486
+ single remaining child at the *start* once the index is off (the default). */
487
+ .dx-repeater-row-header > :last-child {
488
+ margin-inline-start: auto;
489
+ }
490
+
261
491
  .dx-repeater-row-index {
262
492
  font-weight: 600;
263
493
  color: var(--bs-secondary-color);
264
494
  }
495
+
496
+ .dx-repeater-table td {
497
+ vertical-align: middle;
498
+ }
499
+
500
+ /* Sub-fields render with their default mb-3 (matching the standalone-field
501
+ default), which is unwanted spacing inside an already-compact table cell —
502
+ and leaves the field vertically off-centre against the trailing remove
503
+ button, since that residual margin-bottom is inside the td's own
504
+ vertical-align:middle box. Bootstrap's spacing utilities are `!important`
505
+ by design (same as the toast-variant override elsewhere in this project),
506
+ so overriding one requires `!important` too — a plain declaration silently
507
+ loses and the margin survives. */
508
+ .dx-repeater-table td :deep(.mb-3) {
509
+ margin-bottom: 0 !important;
510
+ }
511
+
512
+ .dx-repeater-table-remove-col {
513
+ width: 1%;
514
+ white-space: nowrap;
515
+ text-align: center;
516
+ }
517
+
518
+ /* Match the row's text-input height (same var the switch field uses).
519
+ `--dx-input-height`'s `em` term resolves against the CONSUMING element's
520
+ own font-size, not the input's — so this only lines up if the button's
521
+ font-size equals .form-control's (both derive from Bootstrap's base
522
+ font-size var, but a `size="sm"` DButton sets a smaller one). The button
523
+ is deliberately NOT `size="sm"` here for that reason; min-height still
524
+ keeps it visually compact regardless. */
525
+ .dx-repeater-table-remove-btn {
526
+ display: inline-flex;
527
+ align-items: center;
528
+ justify-content: center;
529
+ min-height: var(--dx-input-height, calc(1.5em + 0.75rem + 2px));
530
+ }
265
531
  </style>