@omnitend/dashboard-for-laravel 0.24.0 → 0.26.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-11T10:04:02.971Z",
2
+ "generated": "2026-07-13T10:49:19.086Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.24.0"
5
+ "version": "0.26.0"
6
6
  },
7
7
  "components": {
8
8
  "base": [
@@ -1121,27 +1121,6 @@
1121
1121
  "slots": [],
1122
1122
  "methods": []
1123
1123
  },
1124
- {
1125
- "name": "DTab",
1126
- "category": "base",
1127
- "filePath": "resources/js/components/base/DTab.vue",
1128
- "description": "",
1129
- "props": [],
1130
- "events": [],
1131
- "slots": [
1132
- {
1133
- "name": "name",
1134
- "description": "",
1135
- "bindings": [
1136
- {
1137
- "name": "name",
1138
- "title": "binding"
1139
- }
1140
- ]
1141
- }
1142
- ],
1143
- "methods": []
1144
- },
1145
1124
  {
1146
1125
  "name": "DTable",
1147
1126
  "category": "base",
@@ -1430,6 +1409,13 @@
1430
1409
  "default": "'wrap'",
1431
1410
  "description": "What the navbar actions slot does below `md`: `\"wrap\"` to its own\nfull-width row, `\"hide\"` to remove it (relocate actions into the page on\nphones). Forwarded to DXDashboardNavbar."
1432
1411
  },
1412
+ {
1413
+ "name": "userMenuLabel",
1414
+ "type": "string",
1415
+ "required": false,
1416
+ "default": "'User menu'",
1417
+ "description": "Accessible name for the navbar's user-menu trigger. Forwarded to DXDashboardNavbar."
1418
+ },
1433
1419
  {
1434
1420
  "name": "collapsibleGroups",
1435
1421
  "type": "boolean",
@@ -1528,6 +1514,13 @@
1528
1514
  "required": false,
1529
1515
  "default": "\"wrap\"",
1530
1516
  "description": "What the actions slot does below the `md` breakpoint: `\"wrap\"` moves it\nto its own full-width row (the bar grows), `\"hide\"` removes it entirely\n(for apps that relocate page actions into the page on phones)."
1517
+ },
1518
+ {
1519
+ "name": "userMenuLabel",
1520
+ "type": "string",
1521
+ "required": false,
1522
+ "default": "\"User menu\"",
1523
+ "description": "Accessible name for the user-menu trigger. Without one, a screen reader\nannounces the trigger as the avatar's initial (\"J\") rather than as a menu."
1531
1524
  }
1532
1525
  ],
1533
1526
  "events": [
@@ -2761,7 +2754,7 @@
2761
2754
  "name": "perPageOptions",
2762
2755
  "type": "Array",
2763
2756
  "required": false,
2764
- "default": "() => [10, 25, 50, 100]",
2757
+ "default": "() => [10, 20, 50, 100]",
2765
2758
  "description": "Per-page options for selector"
2766
2759
  },
2767
2760
  {
@@ -2914,6 +2907,18 @@
2914
2907
  "required": false,
2915
2908
  "description": "Rows currently expanded, as a `v-model`. Pair with the `row-expansion`\nslot to render detail content under a row (#112) instead of forcing every\nper-row detail into a modal."
2916
2909
  },
2910
+ {
2911
+ "name": "rowClass",
2912
+ "type": "union",
2913
+ "required": false,
2914
+ "description": "Class(es) applied to each row's `<tr>` — a string, or a function of the\nrow. Use for conditional row styling (a variant, a muted/disabled look)\nwithout reaching into the table's internal DOM from global CSS."
2915
+ },
2916
+ {
2917
+ "name": "rowClickable",
2918
+ "type": "TSFunctionType",
2919
+ "required": false,
2920
+ "description": "Whether a given row is actionable. Only relevant when rows are clickable\n(an `editFields` modal or a `row-clicked` listener). A row this returns\n`false` for gets no pointer cursor, no hover highlight, and does not fire\n`row-clicked` or open the edit modal — so a row that isn't clickable\ndoesn't *look* clickable, and a click on it can't quietly do something."
2921
+ },
2917
2922
  {
2918
2923
  "name": "clientSide",
2919
2924
  "type": "boolean",
@@ -3009,6 +3014,28 @@
3009
3014
  "description": "Card header content; overrides the default title heading and the \"New {item}\" button.",
3010
3015
  "bindings": []
3011
3016
  },
3017
+ {
3018
+ "name": "thead-top",
3019
+ "description": "A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.",
3020
+ "bindings": [
3021
+ {
3022
+ "title": "binding",
3023
+ "type": {
3024
+ "name": "object"
3025
+ },
3026
+ "name": "columns",
3027
+ "description": "The number of columns in the table."
3028
+ },
3029
+ {
3030
+ "title": "binding",
3031
+ "type": {
3032
+ "name": "object"
3033
+ },
3034
+ "name": "fields",
3035
+ "description": "The table's fields."
3036
+ }
3037
+ ]
3038
+ },
3012
3039
  {
3013
3040
  "name": "name",
3014
3041
  "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.",
@@ -3268,8 +3295,8 @@
3268
3295
  ]
3269
3296
  },
3270
3297
  "stats": {
3271
- "totalComponents": 74,
3272
- "baseComponents": 57,
3298
+ "totalComponents": 73,
3299
+ "baseComponents": 56,
3273
3300
  "extendedComponents": 17
3274
3301
  }
3275
3302
  }
@@ -1,7 +1,7 @@
1
1
  # Documentation Map
2
2
 
3
3
  > Auto-generated hierarchical overview of all documentation
4
- > Last updated: 2026-07-11T10:04:03.051Z
4
+ > Last updated: 2026-07-13T10:49:19.148Z
5
5
 
6
6
  This file provides a complete map of all available documentation for AI agents and developers.
7
7
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Vue 3 dashboard components for Laravel with Bootstrap Vue Next
4
4
  >
5
- > A dual-package library (NPM + Composer) providing 69 Vue 3 components for building Laravel dashboards with Bootstrap Vue Next.
5
+ > A dual-package library (NPM + Composer) providing 68 Vue 3 components for building Laravel dashboards with Bootstrap Vue Next.
6
6
 
7
7
  ## Installation
8
8
 
@@ -19,7 +19,7 @@
19
19
  - [Theming](/guide/theming): Customize appearance with CSS variables
20
20
  - [TypeScript](/guide/typescript): TypeScript types and best practices
21
21
 
22
- ## Base Components (57 components)
22
+ ## Base Components (56 components)
23
23
 
24
24
  Lightweight type-safe wrappers around Bootstrap Vue Next components providing API stability and consistent theming.
25
25
 
@@ -74,7 +74,6 @@ Lightweight type-safe wrappers around Bootstrap Vue Next components providing AP
74
74
  - [DProgress](/components/base/dprogress): Bootstrap Vue Next Progress wrapper
75
75
  - [DRow](/components/base/drow): Bootstrap Vue Next Row wrapper
76
76
  - [DSpinner](/components/base/dspinner): Bootstrap Vue Next Spinner wrapper
77
- - [DTab](/components/base/dtab): Bootstrap Vue Next Tab wrapper
78
77
  - [DTable](/components/base/dtable): Bootstrap Vue Next Table wrapper
79
78
  - [DTabs](/components/base/dtabs): Bootstrap Vue Next Tabs wrapper
80
79
  - [DToast](/components/base/dtoast): Bootstrap Vue Next Toast wrapper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitend/dashboard-for-laravel",
3
- "version": "0.24.0",
3
+ "version": "0.26.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",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "scripts": {
31
31
  "dev": "vite build --watch",
32
- "build": "vite build && vue-tsc --declaration --emitDeclarationOnly --outDir dist",
32
+ "build": "vite build && node scripts/extract-icon-font.mjs && vue-tsc --declaration --emitDeclarationOnly --outDir dist",
33
33
  "typecheck": "vue-tsc --noEmit",
34
34
  "pretest": "vite build",
35
35
  "test": "vitest",
@@ -162,6 +162,35 @@ $dashboard-sidebar-width: 280px;
162
162
  $dashboard-sidebar-collapsed-width: 80px;
163
163
  $dashboard-navbar-height: 64px;
164
164
 
165
+ // The bar's vertical padding, and the single-row CONTENT BUDGET that follows
166
+ // from it. Everything on the bar's single row — the avatar toggle, whatever a
167
+ // consumer puts in the `actions` or `user-icon` slots — has to fit this, or the
168
+ // bar grows past $dashboard-navbar-height and the header stops lining up with
169
+ // the sidebar's (which is a fixed $dashboard-navbar-height tall).
170
+ //
171
+ // This is the ONE owner of that arithmetic. It used to be spread across four
172
+ // magic numbers in two files with nothing tying them together (#102), so a 40px
173
+ // avatar or a `btn-lg` in the actions slot broke the alignment silently. Our own
174
+ // pieces now size themselves FROM the budget, and it's published as
175
+ // `--dx-navbar-content-height` so consumer slot content can too.
176
+ $dashboard-navbar-bar-padding-y: 0.5rem;
177
+
178
+ // The header's own `border-bottom`. This was the number nobody was counting:
179
+ // `.dashboard-navbar` is border-box, so its 64px min-height has to contain the
180
+ // bar AND this border. Budgeting 64 - 16 = 48px for content (as the old comment
181
+ // did) overshoots the header by exactly 1px, which is what pushed it out of
182
+ // alignment with the sidebar. Part of the arithmetic, so it lives with it.
183
+ $dashboard-navbar-border-width: 1px; // Bootstrap's $border-width
184
+
185
+ // A CSS `calc()` rather than Sass arithmetic, deliberately: the height is in px
186
+ // and the padding in rem, so Sass can't subtract them — and resolving it at
187
+ // runtime is the more correct answer anyway, since the padding then still scales
188
+ // with the root font size.
189
+ $dashboard-navbar-content-height: calc(
190
+ #{$dashboard-navbar-height} - #{$dashboard-navbar-border-width} -
191
+ #{$dashboard-navbar-bar-padding-y} * 2
192
+ );
193
+
165
194
  // ----------------------------------------------------------------------------
166
195
  // Import Bootstrap
167
196
  // ----------------------------------------------------------------------------
@@ -181,12 +210,18 @@ $dashboard-navbar-height: 64px;
181
210
  // bi-*">`) renders without the consumer wiring up their own icon set.
182
211
  //
183
212
  // Imported as SCSS (not the prebuilt CSS) so we can override the font `src` to
184
- // woff2 ONLY — dropping bootstrap-icons' legacy `woff` fallback. In the library
185
- // build Vite inlines CSS `url()` assets as data URIs, so shipping both formats
186
- // doubled the embedded font bytes in `dist/style.css` for no benefit (every
187
- // browser we target reads woff2). The override must come BEFORE the import so
213
+ // woff2 ONLY — dropping bootstrap-icons' legacy `woff` fallback, which no
214
+ // browser we target needs. The override must come BEFORE the import so
188
215
  // bootstrap-icons' own `!default` src doesn't re-add the woff. The bare
189
216
  // `bootstrap-icons/...` specifier resolves through Vite's module resolver.
217
+ //
218
+ // The font itself does NOT ship inside `dist/style.css`. Vite always inlines
219
+ // CSS-referenced assets as base64 data URIs in library mode, which is
220
+ // pathological for a woff2 (already Brotli-compressed; base64 inflates it a
221
+ // third; gzip recovers none of it) — inlined, the font alone was 137 KB of the
222
+ // stylesheet's 191 KB gzip. `scripts/extract-icon-font.mjs` writes it back out
223
+ // as a real file post-build, so the browser fetches it only when a `.bi-*`
224
+ // glyph actually renders. See #77.
190
225
  $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2")
191
226
  format("woff2");
192
227
  @import "bootstrap-icons/font/bootstrap-icons";
@@ -287,6 +322,11 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
287
322
  }
288
323
 
289
324
  .dashboard-navbar {
325
+ // Published so slot content can size itself to the budget rather than guess:
326
+ // .my-navbar-action { height: var(--dx-navbar-content-height); }
327
+ --dx-navbar-height: #{$dashboard-navbar-height};
328
+ --dx-navbar-content-height: #{$dashboard-navbar-content-height};
329
+
290
330
  // Floor, not a fixed height: below `md` the search (and actions) drop to
291
331
  // their own rows and the bar must grow to contain them (#93). Single-row
292
332
  // content is shorter than this, so desktop stays exactly this height,
@@ -298,15 +338,22 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
298
338
  background-color: var(--bs-light);
299
339
  }
300
340
 
301
- // The avatar dropdown trigger (DXDashboardNavbar sets this via toggle-class,
302
- // so it lands on the BVN-rendered button — global rule, scoped CSS can't
303
- // reach it). The themed $btn-padding-y (10px) would make it ~54px tall and
304
- // blow the navbar's single-row content budget: 64px header minus the bar's
305
- // 16px padding = 48px. 6px keeps a ~46px tap target (≥ the 44px WCAG
306
- // guideline) while still fitting the budget.
341
+ // The avatar dropdown trigger (DXDashboardNavbar sets this via toggle-class, so
342
+ // it lands on the BVN-rendered button — a global rule, scoped CSS can't reach
343
+ // it). Sized FROM the content budget rather than by a hand-tuned padding that
344
+ // happened to fit it: the themed $btn-padding-y (10px) would make it ~54px tall
345
+ // and push the bar past the header height. Taking the budget as its height keeps
346
+ // it aligned by construction, and gives a 48px tap target (≥ the 44px WCAG
347
+ // guideline) — better than the 46px the old padding produced.
348
+ //
349
+ // Because it's a fixed height, a taller `user-icon` slot (a 40px avatar, say)
350
+ // can no longer grow the bar: it centres inside the budget instead.
307
351
  .dashboard-navbar__user-menu-toggle {
308
- padding-top: 0.375rem;
309
- padding-bottom: 0.375rem;
352
+ height: $dashboard-navbar-content-height;
353
+ display: inline-flex;
354
+ align-items: center;
355
+ padding-top: 0;
356
+ padding-bottom: 0;
310
357
  }
311
358
 
312
359
  // ----------------------------------------------------------------------------
@@ -411,3 +458,67 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
411
458
  0 10px 15px -3px rgba(0, 0, 0, 0.1),
412
459
  0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
413
460
  }
461
+
462
+ // ----------------------------------------------------------------------------
463
+ // Dashboard navbar layout
464
+ // ----------------------------------------------------------------------------
465
+ // These rules live here rather than in DXDashboardNavbar's scoped block so the
466
+ // `md` switch compiles from Bootstrap's own `$grid-breakpoints`, exactly as the
467
+ // `d-md-*` utilities the same component uses do. A media query hardcoded in the
468
+ // SFC is baked into `dist/` at OUR build, so a consumer overriding
469
+ // `$grid-breakpoints` and compiling this file from source would move the
470
+ // utilities and not the media query, leaving a band of widths where the two
471
+ // disagree (#101).
472
+ //
473
+ // Mobile-first: the actions and the search each take their own full-width row
474
+ // below the toggle / title / user-menu row (flex-basis 100% forces the wrap; the
475
+ // bar and the sticky header grow to contain them — #93). The actions row itself
476
+ // wraps, so a wide button group stacks instead of overflowing the viewport.
477
+ //
478
+ // From `md` up everything goes inline: search between the title and the actions
479
+ // (filling the middle; `searchAlign` places its content), actions right-aligned
480
+ // next to the user menu. The actions' `order: 2` holds at every size — only the
481
+ // search and the user-menu cluster swap order across the breakpoint.
482
+
483
+ .dashboard-navbar__bar {
484
+ padding: $dashboard-navbar-bar-padding-y 0;
485
+ }
486
+
487
+ .dashboard-navbar__end {
488
+ margin-left: auto;
489
+ }
490
+
491
+ .dashboard-navbar__actions {
492
+ order: 2;
493
+ flex: 0 0 100%;
494
+ flex-wrap: wrap;
495
+ }
496
+
497
+ .dashboard-navbar__search {
498
+ order: 3;
499
+ flex: 0 0 100%;
500
+ }
501
+
502
+ @include media-breakpoint-up(md) {
503
+ .dashboard-navbar__search {
504
+ order: 1;
505
+ flex: 1 1 auto;
506
+ }
507
+
508
+ .dashboard-navbar__actions {
509
+ flex: 0 1 auto;
510
+ flex-wrap: nowrap;
511
+ margin-left: auto;
512
+ }
513
+
514
+ .dashboard-navbar__end {
515
+ order: 3;
516
+ }
517
+
518
+ // When the actions region is present it carries the push-right auto margin;
519
+ // zero the user-menu cluster's so the two sit adjacent instead of splitting
520
+ // the free space between them.
521
+ .dashboard-navbar__actions ~ .dashboard-navbar__end {
522
+ margin-left: 0;
523
+ }
524
+ }
@@ -6,11 +6,40 @@
6
6
  * `filterFunction`.
7
7
  */
8
8
  <script setup lang="ts">
9
+ import { computed, useAttrs } from "vue";
9
10
  import { BAutocomplete } from "bootstrap-vue-next";
10
11
 
11
12
  defineOptions({
12
13
  inheritAttrs: false,
13
14
  });
15
+
16
+ const attrs = useAttrs();
17
+
18
+ /*
19
+ * Diverges from bvn: the clear (✕) is hidden when there is nothing to clear.
20
+ *
21
+ * BAutocomplete's own `hasSelection` excludes only `null` and `undefined`, so an
22
+ * EMPTY STRING counts as a selection and the ✕ renders on an empty field. That's
23
+ * very visible through DXTable, which uses this control for every `select`
24
+ * column filter: a freshly loaded table with four filters showed four ✕ buttons
25
+ * before the user had done anything, reading as "these have a value to clear"
26
+ * when they don't (#108). An inert button also shouldn't sit in the tab order.
27
+ *
28
+ * A consumer's explicit `no-clear-button` still wins; this only ever hides the
29
+ * button, never forces it on.
30
+ */
31
+ const attr = (name: string, hyphenated: string) =>
32
+ (attrs as Record<string, unknown>)[name] ?? (attrs as Record<string, unknown>)[hyphenated];
33
+
34
+ const hasValue = computed(() => {
35
+ const value = attr("modelValue", "model-value");
36
+ if (Array.isArray(value)) return value.length > 0;
37
+ return value !== null && value !== undefined && value !== "";
38
+ });
39
+
40
+ const noClearButton = computed(
41
+ () => attr("noClearButton", "no-clear-button") === true || !hasValue.value,
42
+ );
14
43
  </script>
15
44
 
16
45
  <template>
@@ -23,7 +52,7 @@ defineOptions({
23
52
  (#53). A plain element root makes it deterministic.
24
53
  -->
25
54
  <div class="d-autocomplete">
26
- <BAutocomplete v-bind="$attrs">
55
+ <BAutocomplete v-bind="$attrs" :no-clear-button="noClearButton">
27
56
  <!-- Dynamically pass through all named slots with their props -->
28
57
  <template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
29
58
  <slot :name="name" v-bind="slotProps" />
@@ -49,6 +78,30 @@ defineOptions({
49
78
  min-width: 0;
50
79
  }
51
80
 
81
+ /*
82
+ The control is an `.input-group` whose input and trigger chevron are SIBLINGS,
83
+ so Bootstrap's `.form-control:focus` ring wraps the input only and stops dead
84
+ where the chevron begins — the outline reads as clipped rather than framing the
85
+ control. Move the ring to the group on `:focus-within` so the whole thing is
86
+ outlined as one object, which is the standard input-group + button pattern
87
+ (#108).
88
+ */
89
+ .d-autocomplete :deep(.input-group:focus-within) {
90
+ border-radius: var(--bs-border-radius);
91
+ box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
92
+ }
93
+
94
+ .d-autocomplete :deep(.input-group:focus-within .form-control:focus),
95
+ .d-autocomplete :deep(.input-group:focus-within .btn:focus) {
96
+ box-shadow: none;
97
+ }
98
+
99
+ /* Keep the seam between input and chevron coloured like the focused control. */
100
+ .d-autocomplete :deep(.input-group:focus-within .form-control),
101
+ .d-autocomplete :deep(.input-group:focus-within .b-autocomplete-trigger) {
102
+ border-color: var(--bs-primary);
103
+ }
104
+
52
105
  /*
53
106
  The trigger renders as a heavy `btn btn-secondary` (dark), which reads as a
54
107
  detached dark block. Soften it to a subtle bordered chevron that matches the
@@ -47,6 +47,7 @@
47
47
  :user="user"
48
48
  :search-align="searchAlign"
49
49
  :actions-on-mobile="actionsOnMobile"
50
+ :user-menu-label="userMenuLabel"
50
51
  @toggle-sidebar="toggleSidebar"
51
52
  >
52
53
  <!-- Dynamically forward all navbar-* slots by stripping the prefix -->
@@ -154,6 +155,9 @@ interface Props {
154
155
  */
155
156
  actionsOnMobile?: NavbarActionsOnMobile;
156
157
 
158
+ /** Accessible name for the navbar's user-menu trigger. Forwarded to DXDashboardNavbar. */
159
+ userMenuLabel?: string;
160
+
157
161
  /**
158
162
  * Turn sidebar group headers into accordion toggles that collapse/expand
159
163
  * their items. When off (default), every group is permanently expanded.
@@ -189,6 +193,7 @@ const props = withDefaults(defineProps<Props>(), {
189
193
  contentMaxWidth: '1140px',
190
194
  searchAlign: 'start',
191
195
  actionsOnMobile: 'wrap',
196
+ userMenuLabel: 'User menu',
192
197
  });
193
198
 
194
199
  const collapsed = ref(false);
@@ -6,11 +6,11 @@
6
6
  with a `navbar-` prefix.
7
7
  -->
8
8
  <template>
9
- <header class="dashboard-navbar border-bottom">
9
+ <header ref="headerRef" class="dashboard-navbar border-bottom">
10
10
  <DContainer fluid>
11
11
  <!-- Flex bar that wraps: below `md` the search drops to its own
12
12
  full-width row beneath the toggle/title/user-menu row. -->
13
- <div class="dashboard-navbar__bar d-flex flex-wrap align-items-center gap-3">
13
+ <div ref="barRef" class="dashboard-navbar__bar d-flex flex-wrap align-items-center gap-3">
14
14
  <div class="dashboard-navbar__start d-flex align-items-center gap-3">
15
15
  <DButton
16
16
  variant="link"
@@ -83,6 +83,12 @@
83
83
  no-caret
84
84
  >
85
85
  <template #button-content>
86
+ <!-- Names the control. A visually-hidden label rather than an
87
+ `aria-label`, because an aria-label REPLACES the element's
88
+ content for assistive tech — it would silence the avatar's
89
+ notification-badge text, which is the one thing in there
90
+ worth announcing (#113). -->
91
+ <span class="visually-hidden">{{ userMenuLabel }}</span>
86
92
  <!--
87
93
  @slot Custom user avatar/icon in the dropdown trigger. To decorate the default avatar (e.g. add a notification dot) rather than replace it, render `DXUserAvatar` — the same component used here, so the styling comes with it.
88
94
  @binding {string} initial The first letter of the user's name.
@@ -107,7 +113,7 @@
107
113
  </template>
108
114
 
109
115
  <script setup lang="ts">
110
- import { computed } from "vue";
116
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
111
117
  import DContainer from "../base/DContainer.vue";
112
118
  import DButton from "../base/DButton.vue";
113
119
  import DDropdown from "../base/DDropdown.vue";
@@ -137,12 +143,18 @@ const props = withDefaults(
137
143
  * (for apps that relocate page actions into the page on phones).
138
144
  */
139
145
  actionsOnMobile?: NavbarActionsOnMobile;
146
+ /**
147
+ * Accessible name for the user-menu trigger. Without one, a screen reader
148
+ * announces the trigger as the avatar's initial ("J") rather than as a menu.
149
+ */
150
+ userMenuLabel?: string;
140
151
  }>(),
141
152
  {
142
153
  user: null,
143
154
  pageTitle: "",
144
155
  searchAlign: "start",
145
156
  actionsOnMobile: "wrap",
157
+ userMenuLabel: "User menu",
146
158
  },
147
159
  );
148
160
 
@@ -176,6 +188,72 @@ defineEmits<{
176
188
  toggleSidebar: [];
177
189
  }>();
178
190
 
191
+ /*
192
+ * The 64px invariant, made loud instead of silent (#102).
193
+ *
194
+ * The bar aligns with the sidebar's fixed-height header only while its
195
+ * single-row content fits the budget (`--dx-navbar-content-height`). Our own
196
+ * chrome is sized FROM that budget so it can't break it — but slot content is
197
+ * the consumer's, and a `btn-lg` in `actions` or an oversized `user-icon` grows
198
+ * the bar and quietly misaligns the whole shell.
199
+ *
200
+ * We can't cap slot content without clipping it, which would be worse. So we
201
+ * detect the overflow and say so, rather than letting it pass unnoticed.
202
+ *
203
+ * "Single row" is measured (do all the bar's children share an offsetTop?),
204
+ * NOT inferred from a breakpoint — below `md` the bar is *supposed* to grow as
205
+ * the search and actions wrap (#93), and hardcoding a width here would
206
+ * reintroduce exactly the duplicate-breakpoint problem #101 removed.
207
+ */
208
+ const headerRef = ref<HTMLElement | null>(null);
209
+ const barRef = ref<HTMLElement | null>(null);
210
+ let resizeObserver: ResizeObserver | null = null;
211
+ let warnedAboutHeight = false;
212
+
213
+ const checkContentBudget = () => {
214
+ const header = headerRef.value;
215
+ const bar = barRef.value;
216
+ if (warnedAboutHeight || !header || !bar) return;
217
+
218
+ const children = [...bar.children] as HTMLElement[];
219
+ if (children.length === 0) return;
220
+
221
+ // Compare vertical CENTRES, not `offsetTop`: the bar centres its items, so on
222
+ // a single row their tops differ by however much their heights differ. (An
223
+ // offsetTop comparison silently never fires — it was the first thing tried.)
224
+ const centres = children.map((child) => child.offsetTop + child.offsetHeight / 2);
225
+ const isSingleRow = centres.every((centre) => Math.abs(centre - centres[0]) < 1);
226
+ if (!isSingleRow) return; // wrapped: growing is correct here (#93).
227
+
228
+ const budget = parseFloat(
229
+ getComputedStyle(header).getPropertyValue("--dx-navbar-height"),
230
+ );
231
+ if (!budget) return;
232
+
233
+ // Sub-pixel tolerance: a fractional layout shouldn't trip this.
234
+ if (header.getBoundingClientRect().height > budget + 0.5) {
235
+ warnedAboutHeight = true;
236
+ // eslint-disable-next-line no-console
237
+ console.warn(
238
+ `[DXDashboardNavbar] The bar is taller than ${budget}px, so it no longer lines up with the sidebar header. ` +
239
+ "Something on its single row exceeds the content budget — size tall slot content " +
240
+ "(actions, user-icon) against `var(--dx-navbar-content-height)`.",
241
+ );
242
+ }
243
+ };
244
+
245
+ onMounted(() => {
246
+ if (typeof window === "undefined" || typeof ResizeObserver === "undefined") return;
247
+ resizeObserver = new ResizeObserver(checkContentBudget);
248
+ if (headerRef.value) resizeObserver.observe(headerRef.value);
249
+ checkContentBudget();
250
+ });
251
+
252
+ onBeforeUnmount(() => {
253
+ resizeObserver?.disconnect();
254
+ resizeObserver = null;
255
+ });
256
+
179
257
  const getUserInitial = (user: { name: string } | null) => {
180
258
  if (!user?.name) return "";
181
259
  return user.name.charAt(0).toUpperCase();
@@ -189,61 +267,25 @@ const getUserInitial = (user: { name: string } | null) => {
189
267
  z-index: 1000;
190
268
  }
191
269
 
270
+ /* The bar's vertical padding — and the single-row content budget derived from
271
+ it — is owned by theme.scss (`$dashboard-navbar-bar-padding-y`), so the
272
+ geometry has one source (#102). Only the floor lives here. */
192
273
  .dashboard-navbar__bar {
193
274
  min-height: 3.5rem;
194
- padding: 0.5rem 0;
195
- }
196
-
197
- /* Push the user-menu cluster to the right. (The actions region carries its
198
- own auto margin from `md` up — see the sibling rule below.) */
199
- .dashboard-navbar__end {
200
- margin-left: auto;
201
275
  }
202
276
 
203
277
  /*
204
- * Mobile-first: the actions and the search each sit on their own full-width
205
- * row below the toggle / title / user-menu row (flex-basis 100% forces the
206
- * wrap; the bar and the sticky header grow to contain them — #93). The
207
- * actions row itself wraps so a wide button group stacks instead of
208
- * overflowing the viewport.
209
- * From `md` up everything moves inline: search between the title and the
210
- * actions (grows to fill the middle; `searchAlign` controls where its content
211
- * sits), actions right-aligned next to the user menu. The actions' order 2
212
- * holds at every size — only search and end change order across the
213
- * breakpoint.
278
+ * The bar's responsive LAYOUT (the order/flex rules and the `md` switch) lives
279
+ * in theme.scss, not here — see "Dashboard navbar layout" there.
280
+ *
281
+ * It has to: the `md` breakpoint is expressed twice in this component, once as
282
+ * Bootstrap utility classes (`d-none d-md-block` on the title, `d-md-flex` from
283
+ * `actionsOnMobile: "hide"`) and once as a media query. A media query in a
284
+ * scoped SFC block is compiled at OUR build with OUR `$grid-breakpoints` and
285
+ * baked into `dist/`, so a consumer who overrides `$grid-breakpoints` and
286
+ * compiles `theme.scss` from source would move the utilities and NOT the media
287
+ * query — leaving a band of viewport widths where the two disagree (#101).
288
+ * Compiling the layout from `media-breakpoint-up(md)` in theme.scss keeps both
289
+ * derived from the same variable in any build.
214
290
  */
215
- .dashboard-navbar__actions {
216
- order: 2;
217
- flex: 0 0 100%;
218
- flex-wrap: wrap;
219
- }
220
-
221
- .dashboard-navbar__search {
222
- order: 3;
223
- flex: 0 0 100%;
224
- }
225
-
226
- @media (min-width: 768px) {
227
- .dashboard-navbar__search {
228
- order: 1;
229
- flex: 1 1 auto;
230
- }
231
-
232
- .dashboard-navbar__actions {
233
- flex: 0 1 auto;
234
- flex-wrap: nowrap;
235
- margin-left: auto;
236
- }
237
-
238
- .dashboard-navbar__end {
239
- order: 3;
240
- }
241
-
242
- /* When the actions region is present it carries the push-right auto margin;
243
- zero the user-menu cluster's so the two sit adjacent instead of having
244
- the free space split between them. */
245
- .dashboard-navbar__actions ~ .dashboard-navbar__end {
246
- margin-left: 0;
247
- }
248
- }
249
291
  </style>