@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.
- package/dist/components/extended/DXDashboard.vue.d.ts +3 -0
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +6 -0
- package/dist/components/extended/DXTable.vue.d.ts +7 -0
- package/dist/dashboard-for-laravel.js +5523 -5478
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/style.css +1 -1
- package/docs/public/api-reference.json +17 -3
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/css/theme.scss +113 -8
- package/resources/js/components/base/DAutocomplete.vue +54 -1
- package/resources/js/components/extended/DXDashboard.vue +5 -0
- package/resources/js/components/extended/DXDashboardNavbar.vue +96 -54
- package/resources/js/components/extended/DXTable.vue +44 -9
- package/resources/js/components/extended/DXUserAvatar.vue +5 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generated": "2026-07-
|
|
2
|
+
"generated": "2026-07-11T11:02:20.726Z",
|
|
3
3
|
"package": {
|
|
4
4
|
"name": "@omnitend/dashboard-for-laravel",
|
|
5
|
-
"version": "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,
|
|
2778
|
+
"default": "() => [10, 20, 50, 100]",
|
|
2765
2779
|
"description": "Per-page options for selector"
|
|
2766
2780
|
},
|
|
2767
2781
|
{
|
package/docs/public/docs-map.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Documentation Map
|
|
2
2
|
|
|
3
3
|
> Auto-generated hierarchical overview of all documentation
|
|
4
|
-
> Last updated: 2026-07-
|
|
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
package/resources/css/theme.scss
CHANGED
|
@@ -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
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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 ||
|
|
83
|
+
:placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
|
|
84
84
|
size="sm"
|
|
85
85
|
open-on-focus
|
|
86
|
-
@update:model-value="
|
|
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 ||
|
|
179
|
+
:placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
|
|
180
180
|
size="sm"
|
|
181
181
|
open-on-focus
|
|
182
|
-
@update:model-value="
|
|
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 ||
|
|
276
|
+
:placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
|
|
277
277
|
size="sm"
|
|
278
278
|
open-on-focus
|
|
279
|
-
@update:model-value="
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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>
|