@omnitend/dashboard-for-laravel 0.24.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.
@@ -1,8 +1,8 @@
1
1
  {
2
- "generated": "2026-07-11T10:04:02.971Z",
2
+ "generated": "2026-07-11T11:02:20.726Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.24.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-11T10:04:03.051Z
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.24.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>
@@ -80,10 +80,10 @@
80
80
  v-else-if="field.filter === 'select'"
81
81
  :model-value="effectiveFilters[filterKeyFor(field)] || ''"
82
82
  :options="getFieldFilterOptions(field)"
83
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
83
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
84
84
  size="sm"
85
85
  open-on-focus
86
- @update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
86
+ @update:model-value="handleSelectFilterChange(field, $event)"
87
87
  />
88
88
 
89
89
  <!-- Number Filter -->
@@ -176,10 +176,10 @@
176
176
  v-else-if="field.filter === 'select'"
177
177
  :model-value="effectiveFilters[filterKeyFor(field)] || ''"
178
178
  :options="getFieldFilterOptions(field)"
179
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
179
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
180
180
  size="sm"
181
181
  open-on-focus
182
- @update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
182
+ @update:model-value="handleSelectFilterChange(field, $event)"
183
183
  />
184
184
 
185
185
  <!-- Number Filter -->
@@ -273,10 +273,10 @@
273
273
  v-else-if="field.filter === 'select'"
274
274
  :model-value="effectiveFilters[filterKeyFor(field)] || ''"
275
275
  :options="getFieldFilterOptions(field)"
276
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
276
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
277
277
  size="sm"
278
278
  open-on-focus
279
- @update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
279
+ @update:model-value="handleSelectFilterChange(field, $event)"
280
280
  />
281
281
 
282
282
  <!-- Number Filter -->
@@ -685,6 +685,14 @@ export interface TableField {
685
685
 
686
686
  /** The value sent when the `filterNullText` option is chosen. Default `"null"`. */
687
687
  filterNullValue?: string;
688
+
689
+ /**
690
+ * Text of the "clear this filter" option at the top of a `select` filter's
691
+ * list (default: the filter's placeholder, e.g. "All statuses"). The list
692
+ * otherwise offers no way back to *unfiltered* once a value is picked —
693
+ * only the ✕, which isn't discoverable from inside the open dropdown.
694
+ */
695
+ filterAllText?: string;
688
696
  sortable?: boolean;
689
697
  hint?: string;
690
698
  filter?: FilterType;
@@ -928,7 +936,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
928
936
  }),
929
937
  showPagination: true,
930
938
  showPerPageSelector: true,
931
- perPageOptions: () => [10, 25, 50, 100],
939
+ perPageOptions: () => [10, 20, 50, 100],
932
940
  currentPage: 1,
933
941
  // perPage: 10, // Don't set default - let internalPerPage handle it
934
942
  striped: false,
@@ -1228,6 +1236,20 @@ const filterKeyFor = (field: TableField): string => field.filterKey ?? field.key
1228
1236
  // The value that means "has no value" for a select filter (#106).
1229
1237
  const filterNullValueFor = (field: TableField): string => field.filterNullValue ?? 'null';
1230
1238
 
1239
+ // The filter's placeholder, and the label of its "no filter" option — the same
1240
+ // words, so picking the top option visibly returns the field to its resting state.
1241
+ // See getFieldFilterOptions: an empty-string option value makes bvn render no
1242
+ // options at all, so "no filter" needs a value that isn't empty.
1243
+ const FILTER_ALL_VALUE = '__dx_filter_all__';
1244
+
1245
+ const handleSelectFilterChange = (field: TableField, value: unknown) => {
1246
+ const selected = (value ?? '') as string;
1247
+ handleFilterChange(filterKeyFor(field), selected === FILTER_ALL_VALUE ? '' : selected);
1248
+ };
1249
+
1250
+ const filterAllLabelFor = (field: TableField): string =>
1251
+ field.filterAllText ?? field.filterPlaceholder ?? `All ${field.label || field.key}`;
1252
+
1231
1253
  /**
1232
1254
  * Column header text. An explicitly empty `label` means an EMPTY header — an
1233
1255
  * actions column headed `actions` (lowercase, as-keyed) is a leak, not a
@@ -1239,7 +1261,19 @@ const headerLabel = (field: TableField, slotLabel?: string): string =>
1239
1261
  const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1240
1262
  const options: FilterOption[] = [];
1241
1263
 
1242
- // An "Unassigned"-style option, so a select filter can express "no value".
1264
+ // The way back to *unfiltered*. Without it the dropdown lists only the
1265
+ // values, so once a filter is picked the only way to clear it is the ✕ —
1266
+ // which isn't discoverable from inside the open list.
1267
+ //
1268
+ // It carries a SENTINEL, not `''`. bvn drops the ENTIRE option list if any
1269
+ // option's value is an empty string — not just that entry, the whole list —
1270
+ // so the obvious encoding of "no filter" silently empties the dropdown.
1271
+ // `handleSelectFilterChange` translates it back to `''`, which is already
1272
+ // how the filter map represents unset.
1273
+ options.push({ value: FILTER_ALL_VALUE, text: filterAllLabelFor(field) });
1274
+
1275
+ // An "Unassigned"-style option, so a select filter can express "has no value"
1276
+ // — a different thing from "no filter" above.
1243
1277
  if (field.filterNullText) {
1244
1278
  options.push({ value: filterNullValueFor(field), text: field.filterNullText });
1245
1279
  }
@@ -1257,7 +1291,8 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1257
1291
  return [...options, ...serverValues.map(value => ({ value, text: value }))];
1258
1292
  }
1259
1293
 
1260
- return options;
1294
+ // No values to choose from — don't offer a lone "All x" that filters nothing.
1295
+ return [];
1261
1296
  };
1262
1297
 
1263
1298
  // LocalStorage key for perPage preference
@@ -15,7 +15,11 @@
15
15
  <div class="dx-user-avatar">
16
16
  <!-- `.user-avatar` (not a `dx-`-prefixed name) because it's the documented
17
17
  theming hook consumers already override — see the theming guide. -->
18
- <span class="user-avatar">
18
+ <!-- The initial is decorative: it tells a screen-reader user nothing they
19
+ don't know, and it would otherwise leak into the accessible name of
20
+ whatever contains the avatar (the navbar's user-menu trigger). The
21
+ badge's label below stays announced. -->
22
+ <span class="user-avatar" aria-hidden="true">
19
23
  <!-- @slot Replaces the initial inside the disc (e.g. a photo or an icon). -->
20
24
  <slot>{{ resolvedInitial }}</slot>
21
25
  </span>