@omnitend/dashboard-for-laravel 0.16.4 → 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.
@@ -34,6 +34,25 @@ export interface FieldOption extends Record<string, unknown> {
34
34
  * again on model change when `reloadOptionsOnChange` is set.
35
35
  */
36
36
  export type OptionsLoader = (model: any) => Promise<FieldOption[]>;
37
+ /**
38
+ * Label column width for horizontal layout, mirroring Bootstrap Vue Next's
39
+ * `BFormGroup` `labelCols`/`labelCols*` props: either a single width applied
40
+ * at all breakpoints, or a per-breakpoint object (omitted breakpoints fall
41
+ * back to BFormGroup's own defaults, which is how "collapse to vertical
42
+ * below a breakpoint" comes for free — e.g. `{ md: 3 }` stacks below `md`).
43
+ *
44
+ * Deliberately excludes `boolean` (which BFormGroup's own `labelCols` prop
45
+ * accepts): Vue's type-based `defineProps` auto-defaults an omitted prop to
46
+ * `false` (not `undefined`) whenever its declared type includes `boolean`,
47
+ * which would silently break the `field.labelCols ?? formLabelCols ?? 3`
48
+ * fallback chain used to resolve this value.
49
+ */
50
+ export type LabelCols = number | {
51
+ sm?: number;
52
+ md?: number;
53
+ lg?: number;
54
+ xl?: number;
55
+ };
37
56
  /**
38
57
  * Field definition shared by every form renderer.
39
58
  *
@@ -123,9 +142,21 @@ export interface FieldDefinition {
123
142
  inputProps?: Record<string, any>;
124
143
  /**
125
144
  * Render the field full-width with no label wrapper, delegating its
126
- * content to the `#span(<key>)` slot. Useful for custom blocks.
145
+ * content to the `#span(<key>)` slot. Useful for custom blocks. Also
146
+ * doubles as the "fill both columns" escape hatch in horizontal layout —
147
+ * a span field always bypasses the label/content column split.
127
148
  */
128
149
  span?: boolean;
150
+ /**
151
+ * Per-field override of DXForm's `layout` prop ("vertical" | "horizontal").
152
+ * Falls back to the form-level `layout` when omitted.
153
+ */
154
+ layout?: "vertical" | "horizontal";
155
+ /**
156
+ * Per-field override of DXForm's `labelCols` prop, for horizontal
157
+ * layout. Falls back to the form-level `labelCols` when omitted.
158
+ */
159
+ labelCols?: LabelCols;
129
160
  /**
130
161
  * Component rendered for `type: "component"` fields. Receives
131
162
  * `modelValue`, `field`, `model` props and emits `update:modelValue`.
@@ -144,6 +175,24 @@ export interface FieldDefinition {
144
175
  /** Minimum / maximum number of repeater rows. */
145
176
  minItems?: number;
146
177
  maxItems?: number;
178
+ /**
179
+ * For `repeater` fields: `"cards"` (default) stacks each row as its own
180
+ * card with sub-fields listed vertically — best for rows with several
181
+ * or complex sub-fields. `"table"` renders one row per item with
182
+ * sub-fields as columns — far more compact for simple child-row lists
183
+ * (e.g. 2-3 short sub-fields). Named `repeaterLayout` (not `layout`) to
184
+ * avoid colliding with the unrelated form-level `layout` prop
185
+ * ("vertical" | "horizontal") every field type accepts.
186
+ */
187
+ repeaterLayout?: "cards" | "table";
188
+ /**
189
+ * For `repeater` fields in `cards` layout: show each row's 1-based
190
+ * position (e.g. "1", "2") next to its Remove button. Off by default —
191
+ * only meaningful once a consumer explicitly wants it (e.g. rows the
192
+ * user thinks of as numbered/ordered); most repeaters don't need it.
193
+ * Not applicable to `table` layout, which has no equivalent column.
194
+ */
195
+ showRowIndex?: boolean;
147
196
  /**
148
197
  * For `repeater` fields backed by an upsert-children API contract (rows
149
198
  * carrying a persisted `id` are only deleted server-side when submitted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitend/dashboard-for-laravel",
3
- "version": "0.16.4",
3
+ "version": "0.17.0",
4
4
  "description": "Vue 3 dashboard components for Laravel with Bootstrap Vue Next",
5
5
  "type": "module",
6
6
  "main": "./dist/dashboard-for-laravel.umd.cjs",
@@ -171,6 +171,17 @@ $dashboard-navbar-height: 64px;
171
171
  );
172
172
  }
173
173
 
174
+ // Bootstrap's `.input-group` defaults to `flex-wrap: wrap`, letting a
175
+ // narrow container (e.g. a compact table cell, a small screen) stack the
176
+ // prepend/append affix onto its own line below the input. That breaks the
177
+ // visual "one control" continuity an input-group exists to provide — an
178
+ // affix + input should shrink/scroll before it wraps, never stack. Applies
179
+ // globally (currency/percentage fields, DAutocomplete's own `:deep()`
180
+ // override becomes redundant but harmless here).
181
+ .input-group {
182
+ flex-wrap: nowrap;
183
+ }
184
+
174
185
  // ----------------------------------------------------------------------------
175
186
  // Dashboard Custom Styles
176
187
  // ----------------------------------------------------------------------------
@@ -21,7 +21,51 @@
21
21
  />
22
22
  </div>
23
23
 
24
- <!-- Checkbox: no label wrapper, label sits beside the control -->
24
+ <!-- Checkbox (horizontal layout): wrapped in DFormGroup for the same
25
+ label/content column split as other field types, instead of the
26
+ label-beside-control layout below. The inline label beside the
27
+ checkbox itself is kept (not deduplicated against the outer label)
28
+ so the control's accessible name doesn't depend on aria-labelledby
29
+ association through the fieldset alone — but its `info` popover is
30
+ omitted here, since rendering the same popover trigger twice (once
31
+ per label) would be a duplicated interactive element, not just
32
+ duplicated text. -->
33
+ <DFormGroup
34
+ v-else-if="field.type === 'checkbox' && isHorizontal"
35
+ :class="field.class || 'mb-3'"
36
+ v-bind="horizontalAttrs"
37
+ >
38
+ <template #label>
39
+ <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
40
+ </template>
41
+ <slot
42
+ v-if="$slots.value"
43
+ name="value"
44
+ :field="field"
45
+ :model="model"
46
+ :value="fieldValue"
47
+ :update="setValue"
48
+ />
49
+ <DFormCheckbox
50
+ v-else
51
+ v-model="fieldValue"
52
+ :disabled="isDisabled || isReadonly"
53
+ v-bind="field.inputProps"
54
+ >
55
+ <DXFieldLabel :label="resolvedLabel" />
56
+ </DFormCheckbox>
57
+
58
+ <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
59
+ {{ form.getError(errorKey) }}
60
+ </DFormInvalidFeedback>
61
+ <slot name="info" :field="field" :model="model" />
62
+ <DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
63
+ <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
64
+ </DFormText>
65
+ <DFormText v-if="field.help">{{ field.help }}</DFormText>
66
+ </DFormGroup>
67
+
68
+ <!-- Checkbox (vertical, default): no label wrapper, label sits beside the control -->
25
69
  <div v-else-if="field.type === 'checkbox'" :class="field.class || 'mb-3'">
26
70
  <!--
27
71
  @slot Replaces the built-in control with a custom value editor.
@@ -67,7 +111,46 @@
67
111
  <DFormText v-if="field.help">{{ field.help }}</DFormText>
68
112
  </div>
69
113
 
70
- <!-- Switch: toggle with contextual on/off text and an on-state style -->
114
+ <!-- Switch (horizontal layout): same column-split treatment as checkbox
115
+ above, including omitting `info` from the inner label to avoid a
116
+ duplicated popover trigger. -->
117
+ <DFormGroup
118
+ v-else-if="field.type === 'switch' && isHorizontal"
119
+ :class="[field.class || 'mb-3', 'dx-switch', { 'dx-switch--on': switchIsOn }]"
120
+ v-bind="horizontalAttrs"
121
+ >
122
+ <template #label>
123
+ <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
124
+ </template>
125
+ <slot
126
+ v-if="$slots.value"
127
+ name="value"
128
+ :field="field"
129
+ :model="model"
130
+ :value="fieldValue"
131
+ :update="setValue"
132
+ />
133
+ <DFormCheckbox
134
+ v-else
135
+ v-model="switchModel"
136
+ switch
137
+ :disabled="isDisabled || isReadonly"
138
+ v-bind="field.inputProps"
139
+ >
140
+ <DXFieldLabel :label="switchText" />
141
+ </DFormCheckbox>
142
+
143
+ <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
144
+ {{ form.getError(errorKey) }}
145
+ </DFormInvalidFeedback>
146
+ <slot name="info" :field="field" :model="model" />
147
+ <DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
148
+ <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
149
+ </DFormText>
150
+ <DFormText v-if="field.help">{{ field.help }}</DFormText>
151
+ </DFormGroup>
152
+
153
+ <!-- Switch (vertical, default): toggle with contextual on/off text and an on-state style -->
71
154
  <div
72
155
  v-else-if="field.type === 'switch'"
73
156
  :class="[field.class || 'mb-3', 'dx-switch', { 'dx-switch--on': switchIsOn }]"
@@ -119,7 +202,7 @@
119
202
 
120
203
  <!-- Repeater: nested, repeatable sub-form -->
121
204
  <div v-else-if="field.type === 'repeater'" :class="field.class || 'mb-3'">
122
- <DFormGroup>
205
+ <DFormGroup v-bind="repeaterHorizontalAttrs">
123
206
  <template #label>
124
207
  <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
125
208
  </template>
@@ -154,9 +237,11 @@
154
237
  </div>
155
238
 
156
239
  <!-- Standard labelled field -->
157
- <DFormGroup v-else :class="field.class || 'mb-3'">
158
- <!-- Label with optional info popover -->
159
- <template #label>
240
+ <DFormGroup v-else :class="field.class || 'mb-3'" v-bind="horizontalAttrs">
241
+ <!-- Label with optional info popover. Omitted entirely (not just
242
+ emptied) when hideLabel is set, so BFormGroup doesn't reserve a
243
+ label column/row for it (see DXRepeater's table layout). -->
244
+ <template v-if="!hideLabel" #label>
160
245
  <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
161
246
  </template>
162
247
 
@@ -380,7 +465,7 @@ import DFormText from "../base/DFormText.vue";
380
465
  import DInputGroup from "../base/DInputGroup.vue";
381
466
  import DXFieldLabel from "./DXFieldLabel.vue";
382
467
  import type { UseFormReturn } from "../../composables/useForm";
383
- import type { FieldDefinition, FieldOption, FieldType } from "../../types";
468
+ import type { FieldDefinition, FieldOption, FieldType, LabelCols } from "../../types";
384
469
  import { getByPath, setByPath } from "../../utils/objectPath";
385
470
 
386
471
  // Async to break the DXField <-> DXRepeater circular import.
@@ -405,12 +490,74 @@ interface Props {
405
490
 
406
491
  /** Error key for validation lookups (defaults to keyPath/field.key). */
407
492
  errorKey?: string;
493
+
494
+ /**
495
+ * Field layout: "vertical" (default, label above input) or "horizontal"
496
+ * (label left, input right). Normally set by DXForm (form-level, with
497
+ * `field.layout` taking precedence); pass directly when using DXField
498
+ * standalone.
499
+ */
500
+ layout?: "vertical" | "horizontal";
501
+
502
+ /** Label column width for horizontal layout (mirrors BFormGroup). */
503
+ labelCols?: LabelCols;
504
+
505
+ /**
506
+ * Skip rendering the field's own label entirely (only affects the
507
+ * standard labelled-field branch). Used by DXRepeater's `table` layout,
508
+ * where a column header already names the field and a per-row label
509
+ * would waste space in the cell.
510
+ */
511
+ hideLabel?: boolean;
408
512
  }
409
513
 
410
514
  const props = defineProps<Props>();
411
515
 
412
516
  const effectiveModel = computed(() => props.model ?? props.form.data);
413
517
 
518
+ // ————————————————— horizontal layout (#66)
519
+
520
+ // A `span` field always bypasses the label/content column split — it's
521
+ // rendered as fully custom full-width content via the very first template
522
+ // branch, before layout is ever considered.
523
+ const isHorizontal = computed(
524
+ () => props.layout === "horizontal" && !props.field.span,
525
+ );
526
+
527
+ /** Translate a `LabelCols` value into the BFormGroup attrs it expects. */
528
+ function labelColsAttrs(cols: LabelCols | undefined): Record<string, any> {
529
+ if (cols === undefined) return {};
530
+ // BFormGroup's own `labelCols` prop accepts a number OR a numeric string
531
+ // (BVN's `Numberish` type) — pass anything that isn't the breakpoint
532
+ // object straight through, rather than gatekeeping on `typeof === "number"`,
533
+ // so a numeric string (e.g. from an unbound template attribute or a
534
+ // JSON-driven field config) still activates horizontal layout instead of
535
+ // silently falling through to an empty breakpoint-object read.
536
+ if (typeof cols !== "object" || cols === null) {
537
+ return { labelCols: cols };
538
+ }
539
+ const attrs: Record<string, any> = {};
540
+ if (cols.sm !== undefined) attrs.labelColsSm = cols.sm;
541
+ if (cols.md !== undefined) attrs.labelColsMd = cols.md;
542
+ if (cols.lg !== undefined) attrs.labelColsLg = cols.lg;
543
+ if (cols.xl !== undefined) attrs.labelColsXl = cols.xl;
544
+ return attrs;
545
+ }
546
+
547
+ // Default to a 3-column label when horizontal but no width was configured,
548
+ // so `layout: "horizontal"` alone is enough to see the effect.
549
+ const horizontalAttrs = computed<Record<string, any>>(() =>
550
+ isHorizontal.value ? labelColsAttrs(props.labelCols ?? 3) : {},
551
+ );
552
+
553
+ // A table-layout repeater is inherently wide — squeezing its own label into a
554
+ // narrow left column (as if it were a single-line input) reads badly, unlike
555
+ // a normal horizontal field. Its label always renders above, regardless of
556
+ // the form/field's horizontal layout setting (#68 follow-up).
557
+ const repeaterHorizontalAttrs = computed<Record<string, any>>(() =>
558
+ props.field.repeaterLayout === "table" ? {} : horizontalAttrs.value,
559
+ );
560
+
414
561
  // Path semantics (getByPath/setByPath) are only used when a parent passes an
415
562
  // explicit keyPath (e.g. a repeater binding `lines.0.price`). For top-level
416
563
  // fields the key is used literally, so a field whose key legitimately contains
@@ -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<{