@omnitend/dashboard-for-laravel 0.23.0 → 0.25.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.
@@ -85,6 +85,16 @@ export interface FieldDefinition {
85
85
  optionsLoader?: OptionsLoader;
86
86
  /** Re-run `optionsLoader` whenever the form model changes. */
87
87
  reloadOptionsOnChange?: boolean;
88
+ /**
89
+ * For `select` fields: render a **searchable** select — type to filter a
90
+ * long option list, while the model still holds `option.value` (an id).
91
+ *
92
+ * A plain `select` is fine for ten options and unusable for hundreds; the
93
+ * `autocomplete` type filters but models the *typed text*, so it can't back
94
+ * a foreign key (picking "Tesco" gives you `"Tesco"`, not `37`). This is the
95
+ * combination high-frequency data entry against a long list needs.
96
+ */
97
+ searchable?: boolean;
88
98
  /** Number of rows for textarea (default: 3) */
89
99
  rows?: number;
90
100
  /** Step for numeric/currency/percentage inputs */
@@ -254,6 +264,17 @@ export interface FieldDefinition {
254
264
  * `false` to render a bare password input.
255
265
  */
256
266
  revealable?: boolean;
267
+ /**
268
+ * Include this field's value in the submitted payload. Default `true`.
269
+ *
270
+ * Set `false` for a **presentational** field — a header, an alert, an
271
+ * explanatory block rendered via `span` — that exists to lay out the form,
272
+ * not to hold data. Without it, `DXTable`'s edit modal seeds its form from
273
+ * every `editFields` key, so those decorative fields are POSTed alongside
274
+ * the real ones (an empty string, an empty array) purely because they were
275
+ * declared.
276
+ */
277
+ submit?: boolean;
257
278
  /**
258
279
  * Conditionally show or hide this field. When omitted the field is
259
280
  * always visible. Boolean or a function of the form model; evaluated
@@ -1,8 +1,8 @@
1
1
  {
2
- "generated": "2026-07-11T08:44:15.505Z",
2
+ "generated": "2026-07-11T11:02:20.726Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.22.0"
5
+ "version": "0.25.0"
6
6
  },
7
7
  "components": {
8
8
  "base": [
@@ -1430,6 +1430,13 @@
1430
1430
  "default": "'wrap'",
1431
1431
  "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
1432
  },
1433
+ {
1434
+ "name": "userMenuLabel",
1435
+ "type": "string",
1436
+ "required": false,
1437
+ "default": "'User menu'",
1438
+ "description": "Accessible name for the navbar's user-menu trigger. Forwarded to DXDashboardNavbar."
1439
+ },
1433
1440
  {
1434
1441
  "name": "collapsibleGroups",
1435
1442
  "type": "boolean",
@@ -1528,6 +1535,13 @@
1528
1535
  "required": false,
1529
1536
  "default": "\"wrap\"",
1530
1537
  "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)."
1538
+ },
1539
+ {
1540
+ "name": "userMenuLabel",
1541
+ "type": "string",
1542
+ "required": false,
1543
+ "default": "\"User menu\"",
1544
+ "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
1545
  }
1532
1546
  ],
1533
1547
  "events": [
@@ -2761,7 +2775,7 @@
2761
2775
  "name": "perPageOptions",
2762
2776
  "type": "Array",
2763
2777
  "required": false,
2764
- "default": "() => [10, 25, 50, 100]",
2778
+ "default": "() => [10, 20, 50, 100]",
2765
2779
  "description": "Per-page options for selector"
2766
2780
  },
2767
2781
  {
@@ -1,7 +1,7 @@
1
1
  # Documentation Map
2
2
 
3
3
  > Auto-generated hierarchical overview of all documentation
4
- > Last updated: 2026-07-11T08:44:15.559Z
4
+ > Last updated: 2026-07-11T11:02:20.808Z
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.23.0",
3
+ "version": "0.25.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",
@@ -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
  // ----------------------------------------------------------------------------
@@ -287,6 +316,11 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
287
316
  }
288
317
 
289
318
  .dashboard-navbar {
319
+ // Published so slot content can size itself to the budget rather than guess:
320
+ // .my-navbar-action { height: var(--dx-navbar-content-height); }
321
+ --dx-navbar-height: #{$dashboard-navbar-height};
322
+ --dx-navbar-content-height: #{$dashboard-navbar-content-height};
323
+
290
324
  // Floor, not a fixed height: below `md` the search (and actions) drop to
291
325
  // their own rows and the bar must grow to contain them (#93). Single-row
292
326
  // content is shorter than this, so desktop stays exactly this height,
@@ -298,15 +332,22 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
298
332
  background-color: var(--bs-light);
299
333
  }
300
334
 
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.
335
+ // The avatar dropdown trigger (DXDashboardNavbar sets this via toggle-class, so
336
+ // it lands on the BVN-rendered button — a global rule, scoped CSS can't reach
337
+ // it). Sized FROM the content budget rather than by a hand-tuned padding that
338
+ // happened to fit it: the themed $btn-padding-y (10px) would make it ~54px tall
339
+ // and push the bar past the header height. Taking the budget as its height keeps
340
+ // it aligned by construction, and gives a 48px tap target (≥ the 44px WCAG
341
+ // guideline) — better than the 46px the old padding produced.
342
+ //
343
+ // Because it's a fixed height, a taller `user-icon` slot (a 40px avatar, say)
344
+ // can no longer grow the bar: it centres inside the budget instead.
307
345
  .dashboard-navbar__user-menu-toggle {
308
- padding-top: 0.375rem;
309
- padding-bottom: 0.375rem;
346
+ height: $dashboard-navbar-content-height;
347
+ display: inline-flex;
348
+ align-items: center;
349
+ padding-top: 0;
350
+ padding-bottom: 0;
310
351
  }
311
352
 
312
353
  // ----------------------------------------------------------------------------
@@ -411,3 +452,67 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
411
452
  0 10px 15px -3px rgba(0, 0, 0, 0.1),
412
453
  0 4px 6px -2px rgba(0, 0, 0, 0.05) !important;
413
454
  }
455
+
456
+ // ----------------------------------------------------------------------------
457
+ // Dashboard navbar layout
458
+ // ----------------------------------------------------------------------------
459
+ // These rules live here rather than in DXDashboardNavbar's scoped block so the
460
+ // `md` switch compiles from Bootstrap's own `$grid-breakpoints`, exactly as the
461
+ // `d-md-*` utilities the same component uses do. A media query hardcoded in the
462
+ // SFC is baked into `dist/` at OUR build, so a consumer overriding
463
+ // `$grid-breakpoints` and compiling this file from source would move the
464
+ // utilities and not the media query, leaving a band of widths where the two
465
+ // disagree (#101).
466
+ //
467
+ // Mobile-first: the actions and the search each take their own full-width row
468
+ // below the toggle / title / user-menu row (flex-basis 100% forces the wrap; the
469
+ // bar and the sticky header grow to contain them — #93). The actions row itself
470
+ // wraps, so a wide button group stacks instead of overflowing the viewport.
471
+ //
472
+ // From `md` up everything goes inline: search between the title and the actions
473
+ // (filling the middle; `searchAlign` places its content), actions right-aligned
474
+ // next to the user menu. The actions' `order: 2` holds at every size — only the
475
+ // search and the user-menu cluster swap order across the breakpoint.
476
+
477
+ .dashboard-navbar__bar {
478
+ padding: $dashboard-navbar-bar-padding-y 0;
479
+ }
480
+
481
+ .dashboard-navbar__end {
482
+ margin-left: auto;
483
+ }
484
+
485
+ .dashboard-navbar__actions {
486
+ order: 2;
487
+ flex: 0 0 100%;
488
+ flex-wrap: wrap;
489
+ }
490
+
491
+ .dashboard-navbar__search {
492
+ order: 3;
493
+ flex: 0 0 100%;
494
+ }
495
+
496
+ @include media-breakpoint-up(md) {
497
+ .dashboard-navbar__search {
498
+ order: 1;
499
+ flex: 1 1 auto;
500
+ }
501
+
502
+ .dashboard-navbar__actions {
503
+ flex: 0 1 auto;
504
+ flex-wrap: nowrap;
505
+ margin-left: auto;
506
+ }
507
+
508
+ .dashboard-navbar__end {
509
+ order: 3;
510
+ }
511
+
512
+ // When the actions region is present it carries the push-right auto margin;
513
+ // zero the user-menu cluster's so the two sit adjacent instead of splitting
514
+ // the free space between them.
515
+ .dashboard-navbar__actions ~ .dashboard-navbar__end {
516
+ margin-left: 0;
517
+ }
518
+ }
@@ -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>
@@ -324,6 +324,30 @@
324
324
  v-bind="inputPropsWithGuards"
325
325
  />
326
326
 
327
+ <!-- Searchable select: type to filter a long option list, while the
328
+ model keeps the option's VALUE (an id), not the typed text. The
329
+ `autocomplete` type below models the text, so it can't back a
330
+ foreign key. -->
331
+ <template v-else-if="field.type === 'select' && field.searchable">
332
+ <DAutocomplete
333
+ v-if="searchableOptionsReady"
334
+ v-model="fieldValue"
335
+ :options="resolvedOptions"
336
+ :placeholder="field.placeholder"
337
+ :required="field.required"
338
+ :state="fieldState"
339
+ :disabled="isDisabled || isReadonly || isPlaintext"
340
+ open-on-focus
341
+ v-bind="controlPropsWithGuards"
342
+ />
343
+ <!-- BAutocomplete resolves the input's display text from the model
344
+ AT MOUNT and doesn't re-derive it when `options` arrive later,
345
+ so an async list would leave the raw id showing ("51" instead
346
+ of "Waitrose") — and a long list is exactly the case that loads
347
+ async. Hold the control until the options are in. -->
348
+ <DFormInput v-else placeholder="Loading…" disabled />
349
+ </template>
350
+
327
351
  <!-- Select (sync or async options) -->
328
352
  <DFormSelect
329
353
  v-else-if="field.type === 'select'"
@@ -535,6 +559,7 @@ import DXSwitch from "./DXSwitch.vue";
535
559
  import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
536
560
  import DFormText from "../base/DFormText.vue";
537
561
  import DInputGroup from "../base/DInputGroup.vue";
562
+ import DAutocomplete from "../base/DAutocomplete.vue";
538
563
  import DButton from "../base/DButton.vue";
539
564
  import DXFieldLabel from "./DXFieldLabel.vue";
540
565
  import type { UseFormReturn } from "../../composables/useForm";
@@ -918,6 +943,12 @@ const isPlaintext = computed(() => resolveMaybe(props.field.plaintext) ?? false)
918
943
 
919
944
  const passwordRevealed = ref(false);
920
945
 
946
+ // See the template: the searchable select can't render before its options
947
+ // resolve, or it shows the raw id instead of the option's label.
948
+ const searchableOptionsReady = computed(
949
+ () => !props.field.optionsLoader || loadedOptions.value !== null,
950
+ );
951
+
921
952
  const isRevealablePassword = computed(
922
953
  () =>
923
954
  props.field.type === "password" &&