@omnitend/dashboard-for-laravel 0.7.0 → 0.8.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 +13 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +15 -0
- package/dist/dashboard-for-laravel.js +3421 -3346
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +4 -4
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/style.css +1 -1
- package/dist/types/navigation.d.ts +6 -0
- package/docs/public/api-reference.json +34 -2
- package/docs/public/docs-map.md +1 -1
- package/package.json +7 -7
- package/resources/css/theme.scss +62 -1
- package/resources/js/components/extended/DXDashboard.vue +17 -0
- package/resources/js/components/extended/DXDashboardSidebar.vue +302 -41
- package/resources/js/types/navigation.ts +6 -0
|
@@ -10,7 +10,13 @@ export interface NavigationItem {
|
|
|
10
10
|
export interface NavigationGroup {
|
|
11
11
|
label?: string;
|
|
12
12
|
items: NavigationItem[];
|
|
13
|
+
/**
|
|
14
|
+
* Per-group override for the sidebar's `collapsibleGroups` behaviour.
|
|
15
|
+
* Set to `false` to keep a group permanently expanded (no toggle header)
|
|
16
|
+
* even when the sidebar has collapsible groups enabled. Defaults to `true`.
|
|
17
|
+
*/
|
|
13
18
|
collapsible?: boolean;
|
|
19
|
+
/** Reserved for a future explicit initial-open hint; not currently wired. */
|
|
14
20
|
collapsed?: boolean;
|
|
15
21
|
visible?: boolean;
|
|
16
22
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generated": "2026-
|
|
2
|
+
"generated": "2026-07-04T04:12:03.097Z",
|
|
3
3
|
"package": {
|
|
4
4
|
"name": "@omnitend/dashboard-for-laravel",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.8.0"
|
|
6
6
|
},
|
|
7
7
|
"components": {
|
|
8
8
|
"base": [
|
|
@@ -1264,6 +1264,20 @@
|
|
|
1264
1264
|
"default": "'/logout'",
|
|
1265
1265
|
"description": "Logout URL for navbar dropdown"
|
|
1266
1266
|
},
|
|
1267
|
+
{
|
|
1268
|
+
"name": "collapsibleGroups",
|
|
1269
|
+
"type": "boolean",
|
|
1270
|
+
"required": false,
|
|
1271
|
+
"default": "false",
|
|
1272
|
+
"description": "Turn sidebar group headers into accordion toggles that collapse/expand\ntheir items. When off (default), every group is permanently expanded."
|
|
1273
|
+
},
|
|
1274
|
+
{
|
|
1275
|
+
"name": "autoCollapseInactiveGroups",
|
|
1276
|
+
"type": "boolean",
|
|
1277
|
+
"required": false,
|
|
1278
|
+
"default": "true",
|
|
1279
|
+
"description": "Only relevant when `collapsibleGroups` is on. `true` (default): only the\nactive-route group starts open and opening one closes the others\n(single-open accordion). `false`: all groups start open, toggled independently."
|
|
1280
|
+
},
|
|
1267
1281
|
{
|
|
1268
1282
|
"name": "storageKey",
|
|
1269
1283
|
"type": "string",
|
|
@@ -1409,6 +1423,20 @@
|
|
|
1409
1423
|
"required": false,
|
|
1410
1424
|
"default": "'Dashboard'",
|
|
1411
1425
|
"description": ""
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
"name": "collapsibleGroups",
|
|
1429
|
+
"type": "boolean",
|
|
1430
|
+
"required": false,
|
|
1431
|
+
"default": "false",
|
|
1432
|
+
"description": "Turn group headers into accordion toggles that collapse/expand their items.\nWhen off (default), every group is rendered permanently expanded."
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
"name": "autoCollapseInactiveGroups",
|
|
1436
|
+
"type": "boolean",
|
|
1437
|
+
"required": false,
|
|
1438
|
+
"default": "true",
|
|
1439
|
+
"description": "Only relevant when `collapsibleGroups` is on.\n`true` (default): only the active-route group starts open, and opening one\ngroup closes the others (single-open accordion).\n`false`: all groups start open and toggle independently."
|
|
1412
1440
|
}
|
|
1413
1441
|
],
|
|
1414
1442
|
"events": [
|
|
@@ -1448,6 +1476,10 @@
|
|
|
1448
1476
|
{
|
|
1449
1477
|
"name": "collapsed",
|
|
1450
1478
|
"title": "binding"
|
|
1479
|
+
},
|
|
1480
|
+
{
|
|
1481
|
+
"name": "is-expanded",
|
|
1482
|
+
"title": "binding"
|
|
1451
1483
|
}
|
|
1452
1484
|
]
|
|
1453
1485
|
}
|
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-
|
|
4
|
+
> Last updated: 2026-07-04T04:12:03.185Z
|
|
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.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@inertiajs/vue3": "^2.0.0",
|
|
49
|
-
"axios": "^1.
|
|
49
|
+
"axios": "^1.15.3",
|
|
50
50
|
"vue": "^3.5.13"
|
|
51
51
|
},
|
|
52
52
|
"peerDependenciesMeta": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
61
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
62
|
"bootstrap-vue-next": "^0.45.6",
|
|
63
63
|
"highlight.js": "^11.11.1",
|
|
64
64
|
"pluralize": "^8.0.0",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
"@inertiajs/vue3": "^2.2.15",
|
|
76
76
|
"@types/pluralize": "^0.0.33",
|
|
77
77
|
"@vitejs/plugin-vue": "^5.2.4",
|
|
78
|
-
"@vitest/browser": "^4.
|
|
79
|
-
"@vitest/browser-playwright": "^4.
|
|
78
|
+
"@vitest/browser": "^4.1.8",
|
|
79
|
+
"@vitest/browser-playwright": "^4.1.8",
|
|
80
80
|
"@vue/test-utils": "^2.4.6",
|
|
81
81
|
"astro": "^5.15.1",
|
|
82
82
|
"bootstrap": "^5.3.8",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"unplugin-icons": "^22.5.0",
|
|
89
89
|
"unplugin-vue-components": "^30.0.0",
|
|
90
90
|
"vite": "^6.4.1",
|
|
91
|
-
"vitest": "^4.
|
|
91
|
+
"vitest": "^4.1.8",
|
|
92
92
|
"vitest-browser-vue": "^2.0.0",
|
|
93
93
|
"vue-tsc": "^2.2.12"
|
|
94
94
|
},
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"author": "Omni Tend",
|
|
108
108
|
"repository": {
|
|
109
109
|
"type": "git",
|
|
110
|
-
"url": "https://github.com/omnitend/dashboard-for-laravel.git"
|
|
110
|
+
"url": "git+https://github.com/omnitend/dashboard-for-laravel.git"
|
|
111
111
|
},
|
|
112
112
|
"bugs": {
|
|
113
113
|
"url": "https://github.com/omnitend/dashboard-for-laravel/issues"
|
package/resources/css/theme.scss
CHANGED
|
@@ -169,7 +169,8 @@ $dashboard-navbar-height: 64px;
|
|
|
169
169
|
border-bottom-color: $navbar-dark-toggler-border-color;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
.nav-group-label
|
|
172
|
+
.nav-group-label,
|
|
173
|
+
.nav-group-toggle {
|
|
173
174
|
color: $navbar-dark-color;
|
|
174
175
|
}
|
|
175
176
|
|
|
@@ -197,6 +198,66 @@ $dashboard-navbar-height: 64px;
|
|
|
197
198
|
height: $dashboard-navbar-height;
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
// ----------------------------------------------------------------------------
|
|
202
|
+
// Toasts
|
|
203
|
+
// ----------------------------------------------------------------------------
|
|
204
|
+
// Bootstrap Vue Next renders toasts with Bootstrap's `.text-bg-{variant}`
|
|
205
|
+
// utility, which fills the whole toast with a saturated colour and white text —
|
|
206
|
+
// loud "candy" blocks that are wrong for a dashboard. Restyle them as a calm,
|
|
207
|
+
// neutral surface with a subtle semantic tint + a coloured left accent rail and
|
|
208
|
+
// progress bar. Colour still signals the toast type, but the bold palette stays
|
|
209
|
+
// where it belongs (actions, badges, selected states).
|
|
210
|
+
|
|
211
|
+
.toast {
|
|
212
|
+
overflow: hidden; // clip the bottom progress bar to the rounded corners
|
|
213
|
+
border: 1px solid $border-color;
|
|
214
|
+
border-radius: $border-radius;
|
|
215
|
+
background-color: $white;
|
|
216
|
+
box-shadow:
|
|
217
|
+
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
|
218
|
+
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
219
|
+
// BVN wraps each toast in a <span>, which defeats Bootstrap's built-in
|
|
220
|
+
// `.toast:not(:last-child)` spacing (toasts are no longer direct children of
|
|
221
|
+
// the container). Add the gap on the toast itself so it doesn't rely on that.
|
|
222
|
+
margin-bottom: 0.75rem;
|
|
223
|
+
|
|
224
|
+
.toast-header {
|
|
225
|
+
background-color: transparent;
|
|
226
|
+
border-bottom: 0; // remove the two-tone header/body split
|
|
227
|
+
padding-bottom: 0.25rem;
|
|
228
|
+
color: $dark; // dark, high-contrast title
|
|
229
|
+
font-weight: 600;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.toast-body {
|
|
233
|
+
padding-top: 0.25rem;
|
|
234
|
+
color: $secondary; // muted message text
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Hide the auto-dismiss countdown bar — the ticking animation reads as
|
|
238
|
+
// stressful/urgent, which is wrong for a passive notification. Auto-dismiss
|
|
239
|
+
// still works; only its visual indicator is removed.
|
|
240
|
+
.progress {
|
|
241
|
+
display: none;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Semantic variants: subtle tinted surface. Overrides the saturated
|
|
245
|
+
// `.text-bg-*` fill (compound selector wins on specificity); the dark title /
|
|
246
|
+
// muted body from the base rules above stay.
|
|
247
|
+
@each $name, $color in (
|
|
248
|
+
"success": $success,
|
|
249
|
+
"danger": $danger,
|
|
250
|
+
"warning": $warning,
|
|
251
|
+
"info": $info
|
|
252
|
+
) {
|
|
253
|
+
&.text-bg-#{$name} {
|
|
254
|
+
// !important overrides Bootstrap's `.text-bg-*` helper, which sets a
|
|
255
|
+
// saturated background-color !important that BVN applies per variant.
|
|
256
|
+
background-color: mix($color, $white, 8%) !important;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
200
261
|
// ----------------------------------------------------------------------------
|
|
201
262
|
// Custom Utilities
|
|
202
263
|
// ----------------------------------------------------------------------------
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
:title="title"
|
|
8
8
|
:collapsed="collapsed"
|
|
9
9
|
:hidden="hidden"
|
|
10
|
+
:collapsible-groups="collapsibleGroups"
|
|
11
|
+
:auto-collapse-inactive-groups="autoCollapseInactiveGroups"
|
|
10
12
|
@toggle="toggleSidebar"
|
|
11
13
|
>
|
|
12
14
|
<!-- Dynamically forward all sidebar-* slots by stripping the prefix -->
|
|
@@ -82,6 +84,19 @@ interface Props {
|
|
|
82
84
|
/** Logout URL for navbar dropdown */
|
|
83
85
|
logoutUrl?: string;
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Turn sidebar group headers into accordion toggles that collapse/expand
|
|
89
|
+
* their items. When off (default), every group is permanently expanded.
|
|
90
|
+
*/
|
|
91
|
+
collapsibleGroups?: boolean;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Only relevant when `collapsibleGroups` is on. `true` (default): only the
|
|
95
|
+
* active-route group starts open and opening one closes the others
|
|
96
|
+
* (single-open accordion). `false`: all groups start open, toggled independently.
|
|
97
|
+
*/
|
|
98
|
+
autoCollapseInactiveGroups?: boolean;
|
|
99
|
+
|
|
85
100
|
/** LocalStorage key for sidebar state persistence */
|
|
86
101
|
storageKey?: string;
|
|
87
102
|
|
|
@@ -98,6 +113,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
98
113
|
pageTitle: '',
|
|
99
114
|
user: null,
|
|
100
115
|
logoutUrl: '/logout',
|
|
116
|
+
collapsibleGroups: false,
|
|
117
|
+
autoCollapseInactiveGroups: true,
|
|
101
118
|
storageKey: 'dashboard-sidebar-hidden',
|
|
102
119
|
dashboardId: '',
|
|
103
120
|
});
|
|
@@ -22,52 +22,96 @@
|
|
|
22
22
|
|
|
23
23
|
<nav class="sidebar-nav p-3">
|
|
24
24
|
<template v-for="(group, groupIndex) in navigation" :key="groupIndex">
|
|
25
|
-
<div
|
|
25
|
+
<div
|
|
26
|
+
v-if="group.visible !== false"
|
|
27
|
+
class="nav-group mb-3"
|
|
28
|
+
:class="{ 'nav-group-open': isGroupExpanded(groupIndex, group) }"
|
|
29
|
+
>
|
|
30
|
+
<!-- Collapsible group header (accordion toggle) -->
|
|
31
|
+
<button
|
|
32
|
+
v-if="isGroupToggle(group)"
|
|
33
|
+
type="button"
|
|
34
|
+
class="nav-group-toggle text-uppercase small fw-semibold mb-2 px-2"
|
|
35
|
+
:aria-expanded="isGroupExpanded(groupIndex, group)"
|
|
36
|
+
:aria-controls="groupItemsId(groupIndex)"
|
|
37
|
+
@click="toggleGroup(groupIndex)"
|
|
38
|
+
>
|
|
39
|
+
<span class="nav-group-toggle-label">{{ group.label }}</span>
|
|
40
|
+
<svg
|
|
41
|
+
class="nav-group-chevron"
|
|
42
|
+
width="14"
|
|
43
|
+
height="14"
|
|
44
|
+
viewBox="0 0 14 14"
|
|
45
|
+
fill="none"
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
>
|
|
48
|
+
<!-- Wide, square-capped chevron matching Omni Tend's menu headers.
|
|
49
|
+
Points down when closed; rotated 180° when the group is open. -->
|
|
50
|
+
<path
|
|
51
|
+
d="M11.375 4.8125L7 9.1875L2.625 4.8125"
|
|
52
|
+
stroke="currentColor"
|
|
53
|
+
stroke-width="1.75"
|
|
54
|
+
stroke-linecap="square"
|
|
55
|
+
stroke-linejoin="round"
|
|
56
|
+
/>
|
|
57
|
+
</svg>
|
|
58
|
+
</button>
|
|
59
|
+
|
|
60
|
+
<!-- Static group label (non-collapsible, expanded sidebar) -->
|
|
26
61
|
<div
|
|
27
|
-
v-if="group.label && !collapsed"
|
|
62
|
+
v-else-if="group.label && !collapsed"
|
|
28
63
|
class="nav-group-label text-uppercase small fw-semibold mb-2 px-2"
|
|
29
64
|
>
|
|
30
65
|
{{ group.label }}
|
|
31
66
|
</div>
|
|
32
67
|
|
|
68
|
+
<!-- Divider shown in place of the label when the sidebar rail is collapsed -->
|
|
33
69
|
<div v-if="group.label && collapsed" class="nav-group-divider">
|
|
34
70
|
<hr class="my-2 border-secondary" />
|
|
35
71
|
</div>
|
|
36
72
|
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
>
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}"
|
|
73
|
+
<div
|
|
74
|
+
:id="groupItemsId(groupIndex)"
|
|
75
|
+
class="nav-group-items"
|
|
76
|
+
:class="{ 'nav-group-items--collapsible': isGroupToggle(group) }"
|
|
77
|
+
:inert="isGroupExpanded(groupIndex, group) ? undefined : true"
|
|
78
|
+
>
|
|
79
|
+
<ul class="nav flex-column gap-1">
|
|
80
|
+
<li v-for="(item, itemIndex) in group.items" :key="itemIndex" class="nav-item">
|
|
81
|
+
<slot
|
|
82
|
+
name="link"
|
|
83
|
+
:item="item"
|
|
84
|
+
:is-active="isActive(item.url)"
|
|
85
|
+
:collapsed="collapsed"
|
|
86
|
+
:is-expanded="isGroupExpanded(groupIndex, group)"
|
|
52
87
|
>
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class="
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<span
|
|
61
|
-
v-if="item.badge && !collapsed"
|
|
62
|
-
class="badge ms-auto"
|
|
63
|
-
:class="`bg-${item.badgeColor || 'primary'}`"
|
|
88
|
+
<a
|
|
89
|
+
:href="item.url"
|
|
90
|
+
class="nav-link d-flex align-items-center gap-2 rounded"
|
|
91
|
+
:class="{
|
|
92
|
+
'active': isActive(item.url),
|
|
93
|
+
'justify-content-center': collapsed
|
|
94
|
+
}"
|
|
64
95
|
>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
96
|
+
<component
|
|
97
|
+
v-if="item.icon"
|
|
98
|
+
:is="item.icon"
|
|
99
|
+
class="nav-icon"
|
|
100
|
+
style="width: 20px; height: 20px;"
|
|
101
|
+
/>
|
|
102
|
+
<span v-if="!collapsed" class="nav-label">{{ item.label }}</span>
|
|
103
|
+
<span
|
|
104
|
+
v-if="item.badge && !collapsed"
|
|
105
|
+
class="badge ms-auto"
|
|
106
|
+
:class="`bg-${item.badgeColor || 'primary'}`"
|
|
107
|
+
>
|
|
108
|
+
{{ item.badge }}
|
|
109
|
+
</span>
|
|
110
|
+
</a>
|
|
111
|
+
</slot>
|
|
112
|
+
</li>
|
|
113
|
+
</ul>
|
|
114
|
+
</div>
|
|
71
115
|
</div>
|
|
72
116
|
</template>
|
|
73
117
|
</nav>
|
|
@@ -75,8 +119,8 @@
|
|
|
75
119
|
</template>
|
|
76
120
|
|
|
77
121
|
<script setup lang="ts">
|
|
78
|
-
import { computed, ref, onMounted, watch, nextTick } from 'vue';
|
|
79
|
-
import type { Navigation } from '../../types/navigation';
|
|
122
|
+
import { computed, ref, onMounted, watch, nextTick, useId } from 'vue';
|
|
123
|
+
import type { Navigation, NavigationGroup } from '../../types/navigation';
|
|
80
124
|
|
|
81
125
|
const props = withDefaults(defineProps<{
|
|
82
126
|
navigation: Navigation;
|
|
@@ -84,10 +128,24 @@ const props = withDefaults(defineProps<{
|
|
|
84
128
|
collapsed?: boolean;
|
|
85
129
|
hidden?: boolean;
|
|
86
130
|
title?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Turn group headers into accordion toggles that collapse/expand their items.
|
|
133
|
+
* When off (default), every group is rendered permanently expanded.
|
|
134
|
+
*/
|
|
135
|
+
collapsibleGroups?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Only relevant when `collapsibleGroups` is on.
|
|
138
|
+
* `true` (default): only the active-route group starts open, and opening one
|
|
139
|
+
* group closes the others (single-open accordion).
|
|
140
|
+
* `false`: all groups start open and toggle independently.
|
|
141
|
+
*/
|
|
142
|
+
autoCollapseInactiveGroups?: boolean;
|
|
87
143
|
}>(), {
|
|
88
144
|
collapsed: false,
|
|
89
145
|
hidden: false,
|
|
90
146
|
title: 'Dashboard',
|
|
147
|
+
collapsibleGroups: false,
|
|
148
|
+
autoCollapseInactiveGroups: true,
|
|
91
149
|
});
|
|
92
150
|
|
|
93
151
|
defineEmits<{
|
|
@@ -96,14 +154,115 @@ defineEmits<{
|
|
|
96
154
|
|
|
97
155
|
const sidebarRef = ref<HTMLElement | null>(null);
|
|
98
156
|
|
|
157
|
+
const uid = useId();
|
|
158
|
+
const groupItemsId = (index: number): string => `${uid}-nav-group-${index}`;
|
|
159
|
+
|
|
99
160
|
const brandInitial = computed(() => {
|
|
100
161
|
return props.title.charAt(0).toUpperCase();
|
|
101
162
|
});
|
|
102
163
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
164
|
+
// Normalize URLs for comparison: drop any query string / hash, lowercase, and
|
|
165
|
+
// remove a single trailing slash. Dropping ?query and #hash means an index page
|
|
166
|
+
// carrying filter/pagination params (e.g. `/rotas?page=2`) still matches its
|
|
167
|
+
// `/rotas` nav item.
|
|
168
|
+
const normalizeUrl = (url: string): string =>
|
|
169
|
+
url.toLowerCase().replace(/[?#].*$/, '').replace(/\/$/, '');
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The single best-matching nav item URL for the current route. Prefers an exact
|
|
173
|
+
* match; otherwise the longest ancestor path, so a detail page like
|
|
174
|
+
* `/rotas/507` activates the `/rotas` item. Root `/` only matches exactly — it
|
|
175
|
+
* is a prefix of every path, so it is never treated as an ancestor.
|
|
176
|
+
* Returns the normalized URL of the winning item, or null if nothing matches.
|
|
177
|
+
*/
|
|
178
|
+
const activeUrl = computed<string | null>(() => {
|
|
179
|
+
const current = normalizeUrl(props.currentUrl);
|
|
180
|
+
let best: string | null = null;
|
|
181
|
+
for (const group of props.navigation) {
|
|
182
|
+
if (group.visible === false) continue;
|
|
183
|
+
for (const item of group.items) {
|
|
184
|
+
if (item.visible === false) continue;
|
|
185
|
+
const candidate = normalizeUrl(item.url);
|
|
186
|
+
const matches =
|
|
187
|
+
candidate === current ||
|
|
188
|
+
(candidate !== '' && current.startsWith(candidate + '/'));
|
|
189
|
+
if (matches && (best === null || candidate.length > best.length)) {
|
|
190
|
+
best = candidate;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return best;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const isActive = (url: string): boolean =>
|
|
198
|
+
activeUrl.value !== null && normalizeUrl(url) === activeUrl.value;
|
|
199
|
+
|
|
200
|
+
// Index of the group containing the active route (-1 if none).
|
|
201
|
+
const activeGroupIndex = computed(() =>
|
|
202
|
+
props.navigation.findIndex(
|
|
203
|
+
(group) => group.visible !== false && group.items.some((item) => isActive(item.url))
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// A group renders a clickable toggle header only when collapsible groups are
|
|
208
|
+
// enabled, the sidebar rail is expanded, the group has a label, and the group
|
|
209
|
+
// hasn't individually opted out via `collapsible: false`.
|
|
210
|
+
const isGroupToggle = (group: NavigationGroup): boolean =>
|
|
211
|
+
props.collapsibleGroups &&
|
|
212
|
+
!props.collapsed &&
|
|
213
|
+
!!group.label &&
|
|
214
|
+
group.collapsible !== false;
|
|
215
|
+
|
|
216
|
+
// Whether a group's items are currently shown. Rail-collapsed sidebars always
|
|
217
|
+
// show items (as icons); non-collapsible groups are always expanded.
|
|
218
|
+
const isGroupExpanded = (index: number, group: NavigationGroup): boolean => {
|
|
219
|
+
if (props.collapsed) return true;
|
|
220
|
+
if (!isGroupToggle(group)) return true;
|
|
221
|
+
return openGroups.value.has(index);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const openGroups = ref<Set<number>>(new Set());
|
|
225
|
+
|
|
226
|
+
const computeInitialOpenGroups = (): Set<number> => {
|
|
227
|
+
const next = new Set<number>();
|
|
228
|
+
if (props.autoCollapseInactiveGroups) {
|
|
229
|
+
if (activeGroupIndex.value >= 0) next.add(activeGroupIndex.value);
|
|
230
|
+
} else {
|
|
231
|
+
props.navigation.forEach((group, index) => {
|
|
232
|
+
if (group.visible !== false && group.collapsible !== false) next.add(index);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return next;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Initialise synchronously so the active group is already open on first paint —
|
|
239
|
+
// no post-mount height measurement, so no open/close flicker on load.
|
|
240
|
+
openGroups.value = computeInitialOpenGroups();
|
|
241
|
+
|
|
242
|
+
const toggleGroup = (index: number): void => {
|
|
243
|
+
const wasOpen = openGroups.value.has(index);
|
|
244
|
+
if (props.autoCollapseInactiveGroups) {
|
|
245
|
+
openGroups.value = wasOpen ? new Set() : new Set([index]);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const next = new Set(openGroups.value);
|
|
249
|
+
if (wasOpen) next.delete(index);
|
|
250
|
+
else next.add(index);
|
|
251
|
+
openGroups.value = next;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Ensure the active-route group is open. In single-open mode this switches to
|
|
255
|
+
// it (closing others); otherwise it just adds it to the open set. No-op when
|
|
256
|
+
// there is no active group (e.g. a detail page not present in the nav).
|
|
257
|
+
const openActiveGroup = (): void => {
|
|
258
|
+
if (!props.collapsibleGroups || activeGroupIndex.value < 0) return;
|
|
259
|
+
if (props.autoCollapseInactiveGroups) {
|
|
260
|
+
openGroups.value = new Set([activeGroupIndex.value]);
|
|
261
|
+
} else {
|
|
262
|
+
const next = new Set(openGroups.value);
|
|
263
|
+
next.add(activeGroupIndex.value);
|
|
264
|
+
openGroups.value = next;
|
|
265
|
+
}
|
|
107
266
|
};
|
|
108
267
|
|
|
109
268
|
const scrollToActiveItem = async (smooth = false) => {
|
|
@@ -131,10 +290,19 @@ onMounted(() => {
|
|
|
131
290
|
scrollToActiveItem(false);
|
|
132
291
|
});
|
|
133
292
|
|
|
134
|
-
//
|
|
293
|
+
// Client-side route change: open the newly active group, then scroll to it.
|
|
135
294
|
watch(() => props.currentUrl, () => {
|
|
295
|
+
openActiveGroup();
|
|
136
296
|
scrollToActiveItem(true);
|
|
137
297
|
});
|
|
298
|
+
|
|
299
|
+
// The active group can change without a currentUrl change — e.g. navigation
|
|
300
|
+
// arrives/repopulates after mount (async data, permission gating), so the active
|
|
301
|
+
// route resolves late. Watch the derived index (not the array identity, which
|
|
302
|
+
// can churn on every parent re-render) and open the active group when it lands.
|
|
303
|
+
watch(activeGroupIndex, () => {
|
|
304
|
+
openActiveGroup();
|
|
305
|
+
});
|
|
138
306
|
</script>
|
|
139
307
|
|
|
140
308
|
<style scoped>
|
|
@@ -186,6 +354,99 @@ watch(() => props.currentUrl, () => {
|
|
|
186
354
|
letter-spacing: 0.5px;
|
|
187
355
|
}
|
|
188
356
|
|
|
357
|
+
/* Collapsible group toggle header */
|
|
358
|
+
.nav-group-toggle {
|
|
359
|
+
display: flex;
|
|
360
|
+
align-items: center;
|
|
361
|
+
justify-content: space-between;
|
|
362
|
+
width: 100%;
|
|
363
|
+
/* Comfortable, ergonomic click target (matches the nav links' vertical
|
|
364
|
+
rhythm). `px-2` on the element sets only left/right padding, so vertical
|
|
365
|
+
padding here does not fight Bootstrap's utility `!important`. */
|
|
366
|
+
min-height: 2.5rem;
|
|
367
|
+
padding-top: 0.5rem;
|
|
368
|
+
padding-bottom: 0.5rem;
|
|
369
|
+
font-size: 0.75rem;
|
|
370
|
+
letter-spacing: 0.5px;
|
|
371
|
+
background: transparent;
|
|
372
|
+
border: 0;
|
|
373
|
+
/* No `color` here on purpose: theme.scss sets `.nav-group-toggle` to
|
|
374
|
+
$navbar-dark-color so the toggle matches the static .nav-group-label.
|
|
375
|
+
A scoped `color` would override that (equal specificity, later source order). */
|
|
376
|
+
cursor: pointer;
|
|
377
|
+
text-align: left;
|
|
378
|
+
border-radius: var(--bs-border-radius, 0.375rem);
|
|
379
|
+
transition: background-color 0.2s ease;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.nav-group-toggle:hover {
|
|
383
|
+
background-color: rgba(255, 255, 255, 0.08);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.nav-group-toggle:focus-visible {
|
|
387
|
+
outline: 2px solid rgba(255, 255, 255, 0.5);
|
|
388
|
+
outline-offset: 2px;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.nav-group-toggle-label {
|
|
392
|
+
overflow: hidden;
|
|
393
|
+
text-overflow: ellipsis;
|
|
394
|
+
white-space: nowrap;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.nav-group-chevron {
|
|
398
|
+
flex-shrink: 0;
|
|
399
|
+
margin-left: 0.5rem;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.nav-group-open > .nav-group-toggle .nav-group-chevron {
|
|
403
|
+
transform: rotate(180deg);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/*
|
|
407
|
+
* Grid-based collapse: animate rows 0fr -> 1fr so the container height follows
|
|
408
|
+
* its content with no JS measurement. The active group renders with
|
|
409
|
+
* `.nav-group-open` already applied, so its open state paints without a
|
|
410
|
+
* transition (no load flicker). Opacity fades the items in/out alongside the
|
|
411
|
+
* height change for a smoother reveal.
|
|
412
|
+
*
|
|
413
|
+
* This mechanism is applied ONLY to groups that actually collapse
|
|
414
|
+
* (`--collapsible`). A group that never collapses (feature off, rail-collapsed,
|
|
415
|
+
* or `collapsible: false`) keeps a plain wrapper, so its `overflow: hidden`
|
|
416
|
+
* never clips focus outlines / badge shadows for consumers who didn't opt in.
|
|
417
|
+
*/
|
|
418
|
+
.nav-group-items--collapsible {
|
|
419
|
+
display: grid;
|
|
420
|
+
grid-template-rows: 0fr;
|
|
421
|
+
opacity: 0;
|
|
422
|
+
transition: grid-template-rows 0.2s ease, opacity 0.2s ease;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.nav-group-open > .nav-group-items--collapsible {
|
|
426
|
+
grid-template-rows: 1fr;
|
|
427
|
+
opacity: 1;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.nav-group-items--collapsible > .nav {
|
|
431
|
+
overflow: hidden;
|
|
432
|
+
min-height: 0;
|
|
433
|
+
/*
|
|
434
|
+
* Bootstrap's `.nav` sets `flex-wrap: wrap`. While the grid row is collapsing
|
|
435
|
+
* (height near 0), a wrapping flex-column can't stack its items in the tiny
|
|
436
|
+
* height and wraps them into side-by-side columns instead — a visible reflow
|
|
437
|
+
* flash. `nowrap` keeps them stacked and simply clipped by `overflow: hidden`.
|
|
438
|
+
*/
|
|
439
|
+
flex-wrap: nowrap;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* Respect reduced-motion: collapse instantly, no fade. */
|
|
443
|
+
@media (prefers-reduced-motion: reduce) {
|
|
444
|
+
.nav-group-items--collapsible,
|
|
445
|
+
.nav-group-toggle {
|
|
446
|
+
transition: none;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
189
450
|
:deep(.nav-link) {
|
|
190
451
|
padding: 0.625rem 0.75rem;
|
|
191
452
|
transition: all 0.2s ease;
|
|
@@ -11,7 +11,13 @@ export interface NavigationItem {
|
|
|
11
11
|
export interface NavigationGroup {
|
|
12
12
|
label?: string;
|
|
13
13
|
items: NavigationItem[];
|
|
14
|
+
/**
|
|
15
|
+
* Per-group override for the sidebar's `collapsibleGroups` behaviour.
|
|
16
|
+
* Set to `false` to keep a group permanently expanded (no toggle header)
|
|
17
|
+
* even when the sidebar has collapsible groups enabled. Defaults to `true`.
|
|
18
|
+
*/
|
|
14
19
|
collapsible?: boolean;
|
|
20
|
+
/** Reserved for a future explicit initial-open hint; not currently wired. */
|
|
15
21
|
collapsed?: boolean;
|
|
16
22
|
visible?: boolean;
|
|
17
23
|
}
|