@omnitend/dashboard-for-laravel 0.34.0 → 0.35.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.
@@ -1,8 +1,8 @@
1
1
  {
2
- "generated": "2026-07-21T07:46:13.035Z",
2
+ "generated": "2026-07-21T12:16:47.073Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.34.0"
5
+ "version": "0.35.0"
6
6
  },
7
7
  "components": {
8
8
  "base": [
@@ -2427,7 +2427,14 @@
2427
2427
  "type": "union",
2428
2428
  "required": false,
2429
2429
  "default": "\"vertical\"",
2430
- "description": "Form-wide field layout: \"vertical\" (default, label above input) or\n\"horizontal\" (label left, input right). Overridable per-field via\n`field.layout`. A field with `span: true` always renders full-width,\nregardless of layout."
2430
+ "description": "Form-wide field layout:\n\n- `\"vertical\"` (default) — label above input, always.\n- `\"horizontal\"` — label left, input right, always.\n- `\"auto\"` — horizontal when the form's **own container** is at least\n `layoutThreshold` px wide, vertical below that. Container-driven, not\n viewport-driven: a page narrowed by the dashboard sidebar, or a form\n inside a modal, stacks even though the window is wide (which no\n Bootstrap media query can see).\n\nOverridable per-field via `field.layout`. A field with `span: true`\nalways renders full-width, regardless of layout."
2431
+ },
2432
+ {
2433
+ "name": "layoutThreshold",
2434
+ "type": "number",
2435
+ "required": false,
2436
+ "default": "640",
2437
+ "description": "Container width (px) at or above which `layout: \"auto\"` goes horizontal.\nIgnored for the explicit `\"vertical\"`/`\"horizontal\"` layouts.\n\nDefault 640, measured rather than guessed: with the default 3-column\nlabel the label's text area is `containerWidth / 4 - 18` px, so 640 gives\nit 142px — enough for a ~20-character label (\"Unit price (ex VAT)\"\nmeasures 128px at the theme's label font) to stay on one line, with the\ncontrol column still 474px. Below ~584px that label starts wrapping,\nwhich is the cramped-label symptom this exists to avoid. Raise it if your\nlabels run longer, or lower `labelCols` instead."
2431
2438
  },
2432
2439
  {
2433
2440
  "name": "labelCols",
@@ -3314,7 +3321,7 @@
3314
3321
  "type": "boolean",
3315
3322
  "required": false,
3316
3323
  "default": "true",
3317
- "description": "Wrap the table in a `DCard`. Defaults to `true`. Set `false` for a plain,\nborderless variant — the same header, filter row, table and pagination\nrendered directly on the page background (no card border/shadow), for\ndata-heavy admin index pages that read as busy inside a card."
3324
+ "description": "Wrap the table in a `DCard`. Defaults to `true`. Set `false` for a plain,\nborderless variant — the same header, filter row, table and pagination\nrendered directly on the page background (no card border/shadow), for\ndata-heavy admin index pages that read as busy inside a card.\n\nThe table renders FLUSH to the card border (rows and stripes reach the\nedge) while the header and pagination stay padded, which requires the card\nto clip to its radius (`overflow: hidden`).\n\n**Consequence for slot content:** anything you render into a slot that\npositions itself OUTSIDE the card and does not teleport is clipped at the\ncard edge. Library components are unaffected — `DDropdown` teleports to\n`<body>` by default, and the column-filter menus position themselves\n`fixed` — so this only bites a hand-rolled `position: absolute` overlay\n(a custom popover in a `cell(<key>)` or `#header` slot). Teleport it, or\nuse `:card=\"false\"`. Narrowing the clip to the table region is tracked\nas #166."
3318
3325
  },
3319
3326
  {
3320
3327
  "name": "editFields",
@@ -3346,7 +3353,7 @@
3346
3353
  "type": "union",
3347
3354
  "required": false,
3348
3355
  "default": "\"horizontal\"",
3349
- "description": "Field layout for the edit/create modal's form, forwarded to its `DXForm`\n(\"vertical\" = label above input, \"horizontal\" = label left). Defaults to\n**\"horizontal\"** to match the Omni Tend form convention (a raw modal form\npreviously fell back to DXForm's vertical default, which looked out of\nplace next to horizontal page forms). Pass `\"vertical\"` to opt out."
3356
+ "description": "Field layout for the edit/create modal's form, forwarded to its `DXForm`\n(\"vertical\" = label above input, \"horizontal\" = label left). Defaults to\n**\"horizontal\"** to match the Omni Tend form convention (a raw modal form\npreviously fell back to DXForm's vertical default, which looked out of\nplace next to horizontal page forms). Pass `\"vertical\"` to opt out.\n\n`\"auto\"` hands the decision to `DXForm`'s container-width observer, which\nmeasures the FORM rather than the window — so a modal narrowed by its own\nbreakpoint stacks to vertical even on a wide viewport, where a media query\nwould still report \"plenty of room\". See `useContainerWidth`."
3350
3357
  },
3351
3358
  {
3352
3359
  "name": "editLabelCols",
@@ -3578,6 +3585,32 @@
3578
3585
  }
3579
3586
  ]
3580
3587
  },
3588
+ {
3589
+ "name": "`head-end(${field.key})`",
3590
+ "description": "head-end(<fieldKey>) Additive content at the end of a column's own header — a period total above a numeric column, a small badge — keeping DXTable's sort indicator and field hint. Not a `head()` override.",
3591
+ "bindings": [
3592
+ {
3593
+ "name": "name",
3594
+ "title": "binding"
3595
+ },
3596
+ {
3597
+ "title": "binding",
3598
+ "type": {
3599
+ "name": "object"
3600
+ },
3601
+ "name": "field",
3602
+ "description": "The column's field definition."
3603
+ },
3604
+ {
3605
+ "title": "binding",
3606
+ "type": {
3607
+ "name": "string"
3608
+ },
3609
+ "name": "label",
3610
+ "description": "The column's resolved header label."
3611
+ }
3612
+ ]
3613
+ },
3581
3614
  {
3582
3615
  "name": "name",
3583
3616
  "description": "Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.",
@@ -3679,7 +3712,7 @@
3679
3712
  "name": "layout",
3680
3713
  "type": "union",
3681
3714
  "required": false,
3682
- "description": "Field layout forwarded to the modal's DXForm (defaults handled upstream)."
3715
+ "description": "Field layout forwarded to the modal's DXForm (defaults handled upstream).\nIncludes `\"auto\"`, which lets the form stack to vertical when the MODAL is\nnarrow even though the viewport is wide — the container-query case a\nviewport media query cannot see."
3683
3716
  },
3684
3717
  {
3685
3718
  "name": "labelCols",
@@ -3991,7 +4024,7 @@
3991
4024
  "name": "DXTableShell",
3992
4025
  "category": "extended",
3993
4026
  "filePath": "resources/js/components/extended/DXTableShell.vue",
3994
- "description": "Internal chrome wrapper for `DXTable`. Renders its content either inside a `DCard` (the default) or as a plain, borderless block on the page background (`:card=\"false\"`). Extracted so `DXTable`'s ~460-line body and all its slots aren't duplicated across a card/plain `v-if`. Slots: - `#header` — the title / action row. In card mode it fills `DCard`'s header; in plain mode it renders above the table with a subtle bottom rule. - default — the table body (spinner/error/table/pagination).",
4027
+ "description": "Internal chrome wrapper for `DXTable`. Renders its content either inside a `DCard` (the default) or as a plain, borderless block on the page background (`:card=\"false\"`). Extracted so `DXTable`'s ~460-line body and all its slots aren't duplicated across a card/plain `v-if`. Slots: - `#header` — the title / action row. In card mode it fills `DCard`'s header; in plain mode it renders above the table with a subtle bottom rule. - default — the table body (spinner/error/table/pagination). Card mode renders `DCard` in **`no-body`** mode so the table sits FLUSH against the card border. With a `.card-body` in between, a striped table's rows were inset by `--bs-card-spacer-x` (24px) and left a white gutter between the stripes and the border. Without it, DXTable's children — error alert, loading block, table, pagination — become direct children of `.card`, which is already a `flex-direction: column` container, so they still stack in order: .card (overflow: hidden) ├─ .card-header (padded by Bootstrap's card-cap padding) ├─ .table-responsive ← FLUSH │ └─ table └─ .dx-table-pagination (pads itself — see DXTablePagination.vue) Bootstrap 5 has no `.card > .table` rounding rules (that was BS4), so the clipping is done with `overflow: hidden` on the card: the flush table's square top corners then follow the card's radius whether or not a header is present. bvn's popups position `fixed` by default, so they are not clipped by it.",
3995
4028
  "props": [
3996
4029
  {
3997
4030
  "name": "card",
@@ -1,7 +1,7 @@
1
1
  # Documentation Map
2
2
 
3
3
  > Auto-generated hierarchical overview of all documentation
4
- > Last updated: 2026-07-21T07:46:13.078Z
4
+ > Last updated: 2026-07-21T12:16:47.128Z
5
5
 
6
6
  This file provides a complete map of all available documentation for AI agents and developers.
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitend/dashboard-for-laravel",
3
- "version": "0.34.0",
3
+ "version": "0.35.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",
@@ -313,6 +313,32 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
313
313
  flex-wrap: nowrap;
314
314
  }
315
315
 
316
+ // ----------------------------------------------------------------------------
317
+ // Nav tabs
318
+ // ----------------------------------------------------------------------------
319
+ // Chrome recedes, content leads — the same ethos as the muted table headers
320
+ // (#157). Bootstrap gives an INACTIVE tab `--bs-nav-link-color:
321
+ // var(--bs-link-color)`, i.e. the saturated link-blue, so a row of tabs above a
322
+ // form reads as a row of links competing with the fields below. Mute the
323
+ // inactive tabs and let the ACTIVE one (Bootstrap's
324
+ // `--bs-nav-tabs-link-active-color`, the near-black emphasis colour) carry the
325
+ // selection on its own.
326
+ //
327
+ // Driven through Bootstrap's own `--bs-nav-*` variables rather than a raw
328
+ // `color:` — so a consumer can re-louden one tab set by resetting the same
329
+ // variable, `[data-bs-theme="dark"]` remaps the tokens for free, and Bootstrap
330
+ // keeps ownership of the `.active`/`.disabled` resolution order.
331
+ //
332
+ // `--bs-secondary-color` is `rgba(var(--bs-body-color-rgb), .75)` → ~#585b5e on
333
+ // white: 6.83:1, comfortably AA (AAA for body text); 6.53:1 on the theme's
334
+ // `$light` #f8fafc panel. Hover goes to the full body colour (#212529, 16.1:1)
335
+ // so hovering reads as "heading toward selected" rather than "turning into a
336
+ // link".
337
+ .nav-tabs {
338
+ --bs-nav-link-color: var(--bs-secondary-color);
339
+ --bs-nav-link-hover-color: var(--bs-body-color);
340
+ }
341
+
316
342
  // ----------------------------------------------------------------------------
317
343
  // Dashboard Custom Styles
318
344
  // ----------------------------------------------------------------------------
@@ -433,6 +459,40 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
433
459
  color: $secondary; // muted message text
434
460
  }
435
461
 
462
+ // HEADER-LESS toast (`body` but no `title` — the shape every toast created
463
+ // through `useToast().create({ body })` takes). BVN renders it as
464
+ // .toast > .d-flex > (.toast-body + .btn-close)
465
+ // and BVN's own `.toast .btn-close-custom { margin: <pad> <pad> auto }` pins
466
+ // the close X to the TOP of that row, while the `.toast-body` above still
467
+ // carries the reduced `padding-top` that only makes sense when it sits under
468
+ // a `.toast-header`. Result: the message rides high, the X sits lower, and a
469
+ // one-line toast reads as misaligned.
470
+ //
471
+ // With no header there is nothing above the body, so restore symmetric
472
+ // padding (Bootstrap's `.toast-body` is `padding: var(--bs-toast-padding-x)`
473
+ // all round) and centre the row. The `margin-bottom: auto` has to go too —
474
+ // an auto margin outranks `align-items` in flex alignment, so centring alone
475
+ // would leave the X pinned; `margin-block: 0` hands alignment back to the
476
+ // container while keeping BVN's horizontal margin.
477
+ //
478
+ // `:has()` is the structural test — it is the only way to key off "this toast
479
+ // has no header" from CSS, and it is supported in every evergreen browser
480
+ // (Chrome/Edge 105+, Safari 15.4+, Firefox 121+). A browser without it simply
481
+ // keeps today's slightly-off alignment; nothing breaks.
482
+ &:not(:has(.toast-header)) {
483
+ > .d-flex {
484
+ align-items: center;
485
+ }
486
+
487
+ .toast-body {
488
+ padding-top: var(--bs-toast-padding-x);
489
+ }
490
+
491
+ .btn-close-custom {
492
+ margin-block: 0;
493
+ }
494
+ }
495
+
436
496
  // Hide the auto-dismiss countdown bar — the ticking animation reads as
437
497
  // stressful/urgent, which is wrong for a passive notification. Auto-dismiss
438
498
  // still works; only its visual indicator is removed.
@@ -530,7 +590,18 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
530
590
  // (which sets its colours `!important` by design). `.toast` is excluded: it
531
591
  // has its own soft handling (a fainter 50%-white mix via `--bs-toast-bg` for
532
592
  // its large surface) and themed toasts don't carry `.text-bg-*` anyway.
533
- .text-bg-#{$name}:not(.toast) {
593
+ //
594
+ // The exclusion is wrapped in `:where()` DELIBERATELY: `:where()` always
595
+ // contributes ZERO specificity, so this stays a single-class 0,1,0 selector
596
+ // while matching exactly what a bare `:not(.toast)` would. Written bare it is
597
+ // 0,2,0 — and because the declarations are `!important`, a consumer painting
598
+ // a fully custom badge (`.my-status-badge { background: …; }`) could not win
599
+ // with a single-class `!important` rule and had to escalate to a
600
+ // repeated-class hack. One class in, one class out: a consumer's own
601
+ // `!important` single-class rule now overrides us on source order, as it
602
+ // should. (Everything Bootstrap defines for `.text-bg-*` is also 0,1,0
603
+ // `!important` and is imported ABOVE this block, so we still win it.)
604
+ .text-bg-#{$name}:where(:not(.toast)) {
534
605
  background-color: #{$soft-bg} !important;
535
606
  color: #{$soft-text} !important;
536
607
  }
@@ -551,7 +622,14 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
551
622
  // (danger/info's solid IS their emphasis). The variant-less default bar
552
623
  // stays `$progress-bar-bg` (brand navy). `!important` beats the `.bg-*`
553
624
  // utility, which sets its colour `!important` by design.
554
- .progress-bar.bg-#{$name} {
625
+ //
626
+ // `.progress-bar` is only here to SCOPE the rule (large fills, not every
627
+ // `.bg-*` element), so it goes in `:where()` — zero specificity, same
628
+ // matching, and the selector stays 0,1,0 like Bootstrap's own `.bg-*`. Same
629
+ // reasoning as the `.text-bg-*` rule above: at 0,2,0 `!important` a consumer
630
+ // recolouring one bar had to out-specify us instead of just writing
631
+ // `.my-bar { background-color: … !important }`.
632
+ :where(.progress-bar).bg-#{$name} {
555
633
  background-color: #{$solid-bg} !important;
556
634
  color: #{$solid-text};
557
635
  }
@@ -426,7 +426,7 @@ watch(activeGroupIndex, () => {
426
426
  overflow-x: hidden;
427
427
  }
428
428
 
429
- /* Dense rhythm (#95): greendragon's cutover values, adopted as the default —
429
+ /* Dense rhythm (#95): values proven in a production dashboard, adopted as the default —
430
430
  natural-case headers at 0.875rem (no uppercase/letter-spacing), tight group
431
431
  spacing. */
432
432
  .nav-group {
@@ -13,8 +13,9 @@
13
13
  -->
14
14
  <template>
15
15
  <BForm
16
+ ref="formRoot"
16
17
  @submit.prevent="handleSubmit"
17
- :class="{ 'dx-form--horizontal': layout === 'horizontal' }"
18
+ :class="{ 'dx-form--horizontal': resolvedLayout === 'horizontal' }"
18
19
  >
19
20
  <!-- Form-level error message -->
20
21
  <DAlert
@@ -71,7 +72,7 @@
71
72
  :field="field"
72
73
  :form="resolvedForm"
73
74
  :model="model"
74
- :layout="layout"
75
+ :layout="resolvedLayout"
75
76
  :label-cols="labelCols"
76
77
  >
77
78
  <!-- Forward every DXForm slot so the field can render
@@ -105,7 +106,7 @@
105
106
  :field="field"
106
107
  :form="resolvedForm"
107
108
  :model="model"
108
- :layout="layout"
109
+ :layout="resolvedLayout"
109
110
  :label-cols="labelCols"
110
111
  >
111
112
  <!-- Forward every DXForm slot so the field can render its
@@ -166,7 +167,7 @@
166
167
  </template>
167
168
 
168
169
  <script setup lang="ts">
169
- import { computed, watch } from "vue";
170
+ import { computed, ref, watch, type ComponentPublicInstance } from "vue";
170
171
  import { BForm } from "bootstrap-vue-next";
171
172
  import DAlert from "../base/DAlert.vue";
172
173
  import DButton from "../base/DButton.vue";
@@ -176,6 +177,7 @@ import { BTab as DTab } from "bootstrap-vue-next"; // raw BTab: BTabs scans slot
176
177
  import DXFormField from "./DXFormField.vue";
177
178
  import type { UseFormReturn } from "../../composables/useForm";
178
179
  import type { DefineFormReturn } from "../../composables/defineForm";
180
+ import { useContainerWidth } from "../../composables/useContainerWidth";
179
181
  import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
180
182
  import {
181
183
  resolvePredicate as resolvePredicateFor,
@@ -235,12 +237,34 @@ interface Props {
235
237
  cardTabs?: boolean;
236
238
 
237
239
  /**
238
- * Form-wide field layout: "vertical" (default, label above input) or
239
- * "horizontal" (label left, input right). Overridable per-field via
240
- * `field.layout`. A field with `span: true` always renders full-width,
241
- * regardless of layout.
240
+ * Form-wide field layout:
241
+ *
242
+ * - `"vertical"` (default) — label above input, always.
243
+ * - `"horizontal"` — label left, input right, always.
244
+ * - `"auto"` — horizontal when the form's **own container** is at least
245
+ * `layoutThreshold` px wide, vertical below that. Container-driven, not
246
+ * viewport-driven: a page narrowed by the dashboard sidebar, or a form
247
+ * inside a modal, stacks even though the window is wide (which no
248
+ * Bootstrap media query can see).
249
+ *
250
+ * Overridable per-field via `field.layout`. A field with `span: true`
251
+ * always renders full-width, regardless of layout.
242
252
  */
243
- layout?: "vertical" | "horizontal";
253
+ layout?: "vertical" | "horizontal" | "auto";
254
+
255
+ /**
256
+ * Container width (px) at or above which `layout: "auto"` goes horizontal.
257
+ * Ignored for the explicit `"vertical"`/`"horizontal"` layouts.
258
+ *
259
+ * Default 640, measured rather than guessed: with the default 3-column
260
+ * label the label's text area is `containerWidth / 4 - 18` px, so 640 gives
261
+ * it 142px — enough for a ~20-character label ("Unit price (ex VAT)"
262
+ * measures 128px at the theme's label font) to stay on one line, with the
263
+ * control column still 474px. Below ~584px that label starts wrapping,
264
+ * which is the cramped-label symptom this exists to avoid. Raise it if your
265
+ * labels run longer, or lower `labelCols` instead.
266
+ */
267
+ layoutThreshold?: number;
244
268
 
245
269
  /**
246
270
  * Label column width for horizontal layout (mirrors BFormGroup's
@@ -258,6 +282,7 @@ const props = withDefaults(defineProps<Props>(), {
258
282
  card: false,
259
283
  cardTabs: true,
260
284
  layout: "vertical",
285
+ layoutThreshold: 640,
261
286
  });
262
287
 
263
288
  const emit = defineEmits<{
@@ -270,6 +295,55 @@ const slots = defineSlots<Record<string, (props: any) => any>>();
270
295
  /** v-model for the active tab index. */
271
296
  const activeTab = defineModel<number>("activeTab", { default: 0 });
272
297
 
298
+ // ————————————————— container-driven layout (`layout: "auto"`)
299
+
300
+ const formRoot = ref<ComponentPublicInstance | HTMLElement | null>(null);
301
+
302
+ /**
303
+ * The `<form>` element BForm renders. A template ref on a component yields the
304
+ * component instance, so unwrap `$el` — and tolerate either shape rather than
305
+ * assuming one, so a future BForm change (or a functional re-implementation)
306
+ * can't silently leave us observing nothing.
307
+ */
308
+ function resolveFormElement(): HTMLElement | null {
309
+ const rootValue = formRoot.value;
310
+ if (rootValue === null || rootValue === undefined) return null;
311
+ if (rootValue instanceof HTMLElement) return rootValue;
312
+ const element = (rootValue as ComponentPublicInstance).$el;
313
+ return element instanceof HTMLElement ? element : null;
314
+ }
315
+
316
+ // Observe ONLY in auto mode. Returning null for the other layouts means no
317
+ // ResizeObserver is ever attached for the (overwhelmingly common) explicit
318
+ // layouts — existing consumers pay nothing and behave identically.
319
+ const { isBelow: containerIsNarrow } = useContainerWidth(
320
+ () => (props.layout === "auto" ? resolveFormElement() : null),
321
+ {
322
+ // Getter, so a consumer binding `:layout-threshold` to something
323
+ // reactive re-evaluates instead of latching the mount-time value.
324
+ threshold: () => props.layoutThreshold,
325
+ // Guard against an observer feedback loop at the boundary: the vertical
326
+ // layout is TALLER, so a form inside an `overflow:auto` ancestor can
327
+ // gain a scrollbar when it stacks, shrinking its own container by
328
+ // ~15-17px — straight back over the threshold, and it flips forever.
329
+ // A band wider than any scrollbar (24px) makes the crossing one-way
330
+ // until the container genuinely grows.
331
+ hysteresis: 24,
332
+ },
333
+ );
334
+
335
+ /**
336
+ * The layout actually rendered. `vertical`/`horizontal` pass through
337
+ * unconditionally (unchanged behaviour); `auto` resolves from the measured
338
+ * container width, defaulting to `vertical` before the first measurement and
339
+ * under SSR — the stacked layout is legible at any width, so it is the safe
340
+ * thing to render when the width is unknown.
341
+ */
342
+ const resolvedLayout = computed<"vertical" | "horizontal">(() => {
343
+ if (props.layout !== "auto") return props.layout;
344
+ return containerIsNarrow.value ? "vertical" : "horizontal";
345
+ });
346
+
273
347
  // ————————————————— resolve form / fields (accept useForm or defineForm)
274
348
 
275
349
  function isDefineForm(