@omnitend/dashboard-for-laravel 0.9.1 → 0.10.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/DXDashboardNavbar.vue.d.ts +14 -8
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +9 -0
- package/dist/dashboard-for-laravel.js +4776 -4764
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +6 -6
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/style.css +1 -1
- package/dist/types/navigation.d.ts +7 -0
- package/docs/public/api-reference.json +353 -137
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/js/components/extended/DXBasicForm.vue +4 -1
- package/resources/js/components/extended/DXDashboard.vue +11 -0
- package/resources/js/components/extended/DXDashboardNavbar.vue +83 -11
- package/resources/js/components/extended/DXDashboardSidebar.vue +138 -43
- package/resources/js/components/extended/DXField.vue +73 -0
- package/resources/js/components/extended/DXFieldLabel.vue +5 -0
- package/resources/js/components/extended/DXForm.vue +22 -1
- package/resources/js/components/extended/DXRepeater.vue +14 -1
- package/resources/js/components/extended/DXTable.vue +73 -0
- package/resources/js/types/navigation.ts +7 -0
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-04T12:57:30.629Z
|
|
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,5 +1,7 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
|
|
2
|
+
@component
|
|
3
|
+
Deprecated alias of DXForm that renders a flat form (DXForm without a `tabs`
|
|
4
|
+
prop) and warns once on use.
|
|
3
5
|
|
|
4
6
|
A flat form is just DXForm without a `tabs` prop. This wrapper forwards
|
|
5
7
|
everything to DXForm and logs a one-time deprecation warning so existing
|
|
@@ -34,6 +36,7 @@ onMounted(() => {
|
|
|
34
36
|
<DXForm v-bind="($attrs as any)">
|
|
35
37
|
<!-- Forward all slots (#value(<key>), #footer, etc.) to DXForm. -->
|
|
36
38
|
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
|
|
39
|
+
<!-- @slot Passes every DXForm slot (e.g. `value(<key>)`, `footer`) straight through to the wrapped DXForm, along with its slot props. -->
|
|
37
40
|
<slot :name="name" v-bind="slotProps" />
|
|
38
41
|
</template>
|
|
39
42
|
</DXForm>
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
DXDashboard is the full dashboard shell — a collapsible sidebar
|
|
4
|
+
(`DXDashboardSidebar`) alongside a top navbar (`DXDashboardNavbar`) and the
|
|
5
|
+
page content area. It owns sidebar visibility state (persisted to
|
|
6
|
+
localStorage, SSR-safe) and forwards any `sidebar-*` slot to the sidebar and
|
|
7
|
+
any `navbar-*` slot to the navbar, stripping the prefix.
|
|
8
|
+
-->
|
|
1
9
|
<template>
|
|
2
10
|
<div class="dashboard-layout d-flex" :data-dashboard-id="dashboardId">
|
|
3
11
|
<!-- Sidebar -->
|
|
@@ -17,6 +25,7 @@
|
|
|
17
25
|
:key="strippedName"
|
|
18
26
|
#[strippedName]="slotProps"
|
|
19
27
|
>
|
|
28
|
+
<!-- @slot Forwards any `sidebar-*` slot to DXDashboardSidebar with the `sidebar-` prefix stripped (e.g. `sidebar-brand` becomes the sidebar's `brand` slot). -->
|
|
20
29
|
<slot :name="originalName" v-bind="slotProps" />
|
|
21
30
|
</template>
|
|
22
31
|
</DXDashboardSidebar>
|
|
@@ -36,6 +45,7 @@
|
|
|
36
45
|
:key="strippedName"
|
|
37
46
|
#[strippedName]="slotProps"
|
|
38
47
|
>
|
|
48
|
+
<!-- @slot Forwards any `navbar-*` slot to DXDashboardNavbar with the `navbar-` prefix stripped (e.g. `navbar-actions` becomes the navbar's `actions` slot). -->
|
|
39
49
|
<slot :name="originalName" v-bind="slotProps" />
|
|
40
50
|
</template>
|
|
41
51
|
</DXDashboardNavbar>
|
|
@@ -45,6 +55,7 @@
|
|
|
45
55
|
<DContainer fluid>
|
|
46
56
|
<DRow class="justify-content-center">
|
|
47
57
|
<DCol cols="12" xl="10">
|
|
58
|
+
<!-- @slot Default slot for the main page content, rendered in the centred content column. -->
|
|
48
59
|
<slot />
|
|
49
60
|
</DCol>
|
|
50
61
|
</DRow>
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Top navigation bar for the dashboard shell: menu toggle, page title, centred
|
|
4
|
+
search, page-level actions, and the user menu. Usually rendered by
|
|
5
|
+
`DXDashboard`, which forwards these slots with a `navbar-` prefix.
|
|
6
|
+
-->
|
|
1
7
|
<template>
|
|
2
8
|
<header class="dashboard-navbar bg-white border-bottom">
|
|
3
|
-
<DContainer fluid
|
|
4
|
-
|
|
5
|
-
|
|
9
|
+
<DContainer fluid>
|
|
10
|
+
<!-- Flex bar that wraps: below `md` the search drops to its own
|
|
11
|
+
full-width row beneath the toggle/title/user-menu row. -->
|
|
12
|
+
<div class="dashboard-navbar__bar d-flex flex-wrap align-items-center gap-3">
|
|
13
|
+
<div class="dashboard-navbar__start d-flex align-items-center gap-3">
|
|
6
14
|
<DButton
|
|
7
15
|
variant="link"
|
|
8
16
|
class="text-dark p-0"
|
|
9
17
|
@click="$emit('toggleSidebar')"
|
|
10
18
|
aria-label="Toggle sidebar"
|
|
11
19
|
>
|
|
20
|
+
<!-- @slot Custom hamburger/menu icon. Defaults to a three-line SVG. -->
|
|
12
21
|
<slot name="menu-icon">
|
|
13
22
|
<svg
|
|
14
23
|
xmlns="http://www.w3.org/2000/svg"
|
|
@@ -26,13 +35,33 @@
|
|
|
26
35
|
</DButton>
|
|
27
36
|
|
|
28
37
|
<h4 v-if="pageTitle" class="mb-0 fw-semibold d-none d-md-block">{{ pageTitle }}</h4>
|
|
38
|
+
</div>
|
|
29
39
|
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
<div
|
|
41
|
+
v-if="$slots.search"
|
|
42
|
+
class="dashboard-navbar__search d-flex justify-content-center"
|
|
43
|
+
>
|
|
44
|
+
<!-- @slot Search input or component. Centred in the bar on wider screens; drops to its own full-width row below the `md` breakpoint. -->
|
|
45
|
+
<slot name="search" />
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="dashboard-navbar__end d-flex align-items-center gap-3">
|
|
49
|
+
<!-- Page-level primary actions, right-aligned next to the user menu -->
|
|
50
|
+
<div
|
|
51
|
+
v-if="$slots.actions"
|
|
52
|
+
class="dashboard-navbar__actions d-flex align-items-center gap-2"
|
|
53
|
+
>
|
|
54
|
+
<!--
|
|
55
|
+
@slot Page-level primary actions (e.g. a Create button), right-aligned next to the user menu.
|
|
56
|
+
@binding {string} pageTitle The current page title, for context.
|
|
57
|
+
-->
|
|
58
|
+
<slot name="actions" :pageTitle="pageTitle" />
|
|
32
59
|
</div>
|
|
33
|
-
</DCol>
|
|
34
60
|
|
|
35
|
-
|
|
61
|
+
<!--
|
|
62
|
+
@slot Replaces the entire user menu (the default avatar dropdown).
|
|
63
|
+
@binding {object} user The signed-in user.
|
|
64
|
+
-->
|
|
36
65
|
<slot name="user-menu" :user="user">
|
|
37
66
|
<DDropdown
|
|
38
67
|
v-if="user"
|
|
@@ -42,6 +71,10 @@
|
|
|
42
71
|
no-caret
|
|
43
72
|
>
|
|
44
73
|
<template #button-content>
|
|
74
|
+
<!--
|
|
75
|
+
@slot Custom user avatar/icon in the dropdown trigger.
|
|
76
|
+
@binding {string} initial The first letter of the user's name.
|
|
77
|
+
-->
|
|
45
78
|
<slot name="user-icon" :initial="getUserInitial(user)">
|
|
46
79
|
<div class="user-avatar">
|
|
47
80
|
{{ getUserInitial(user) }}
|
|
@@ -49,29 +82,33 @@
|
|
|
49
82
|
</slot>
|
|
50
83
|
</template>
|
|
51
84
|
|
|
85
|
+
<!--
|
|
86
|
+
@slot Items for the default user dropdown menu.
|
|
87
|
+
@binding {object} user The signed-in user.
|
|
88
|
+
-->
|
|
52
89
|
<slot name="user-menu-items" :user="user" />
|
|
53
90
|
</DDropdown>
|
|
54
91
|
</slot>
|
|
55
|
-
</
|
|
56
|
-
</
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
57
94
|
</DContainer>
|
|
58
95
|
</header>
|
|
59
96
|
</template>
|
|
60
97
|
|
|
61
98
|
<script setup lang="ts">
|
|
62
99
|
import DContainer from "../base/DContainer.vue";
|
|
63
|
-
import DRow from "../base/DRow.vue";
|
|
64
|
-
import DCol from "../base/DCol.vue";
|
|
65
100
|
import DButton from "../base/DButton.vue";
|
|
66
101
|
import DDropdown from "../base/DDropdown.vue";
|
|
67
102
|
|
|
68
103
|
withDefaults(
|
|
69
104
|
defineProps<{
|
|
105
|
+
/** The signed-in user shown in the avatar dropdown. `null` hides the menu. */
|
|
70
106
|
user?: {
|
|
71
107
|
name: string;
|
|
72
108
|
email: string;
|
|
73
109
|
[key: string]: any;
|
|
74
110
|
} | null;
|
|
111
|
+
/** Page title shown at the left of the navbar (hidden below the `md` breakpoint). */
|
|
75
112
|
pageTitle?: string;
|
|
76
113
|
}>(),
|
|
77
114
|
{
|
|
@@ -81,6 +118,7 @@ withDefaults(
|
|
|
81
118
|
);
|
|
82
119
|
|
|
83
120
|
defineEmits<{
|
|
121
|
+
/** Emitted when the hamburger menu button is clicked. */
|
|
84
122
|
toggleSidebar: [];
|
|
85
123
|
}>();
|
|
86
124
|
|
|
@@ -97,6 +135,40 @@ const getUserInitial = (user: { name: string } | null) => {
|
|
|
97
135
|
z-index: 1000;
|
|
98
136
|
}
|
|
99
137
|
|
|
138
|
+
.dashboard-navbar__bar {
|
|
139
|
+
min-height: 3.5rem;
|
|
140
|
+
padding: 0.5rem 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Push the user-menu / actions cluster to the right. */
|
|
144
|
+
.dashboard-navbar__end {
|
|
145
|
+
margin-left: auto;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/*
|
|
149
|
+
* Mobile-first: the search sits on its own full-width row below the toggle /
|
|
150
|
+
* title / user-menu row (order after both, flex-basis 100% forces the wrap).
|
|
151
|
+
* From `md` up it moves inline between the title and the user menu and grows to
|
|
152
|
+
* fill the middle, centring its content.
|
|
153
|
+
*/
|
|
154
|
+
.dashboard-navbar__search {
|
|
155
|
+
order: 3;
|
|
156
|
+
flex: 0 0 100%;
|
|
157
|
+
width: 100%;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@media (min-width: 768px) {
|
|
161
|
+
.dashboard-navbar__search {
|
|
162
|
+
order: 1;
|
|
163
|
+
flex: 1 1 auto;
|
|
164
|
+
width: auto;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.dashboard-navbar__end {
|
|
168
|
+
order: 2;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
100
172
|
.user-avatar {
|
|
101
173
|
width: 32px;
|
|
102
174
|
height: 32px;
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Collapsible dashboard sidebar: renders a brand header plus grouped navigation
|
|
4
|
+
from a `navigation` array, highlighting the item matching the current route.
|
|
5
|
+
Groups can be plain labelled sections or accordion toggles that expand/collapse
|
|
6
|
+
their items, and the active-route group opens automatically. The rail itself can
|
|
7
|
+
collapse to an icons-only strip or hide entirely. Provides `brand` and `link`
|
|
8
|
+
slots for custom rendering.
|
|
9
|
+
-->
|
|
1
10
|
<template>
|
|
2
11
|
<aside
|
|
3
12
|
ref="sidebarRef"
|
|
4
13
|
class="dashboard-sidebar text-white"
|
|
5
14
|
:class="{
|
|
6
15
|
'sidebar-collapsed': collapsed,
|
|
7
|
-
'sidebar-hidden': hidden
|
|
16
|
+
'sidebar-hidden': hidden,
|
|
17
|
+
'sidebar-collapsible-groups': collapsibleGroups && !collapsed
|
|
8
18
|
}"
|
|
9
19
|
>
|
|
10
20
|
<div class="sidebar-header p-3">
|
|
11
21
|
<div class="d-flex align-items-center justify-content-between">
|
|
22
|
+
<!--
|
|
23
|
+
@slot Brand/logo area in the sidebar header. Defaults to the title's initial plus the full title (hidden when collapsed).
|
|
24
|
+
@binding {boolean} collapsed Whether the sidebar rail is collapsed to icons only.
|
|
25
|
+
@binding {string} title The sidebar title text.
|
|
26
|
+
-->
|
|
12
27
|
<slot name="brand" :collapsed="collapsed" :title="title">
|
|
13
28
|
<div class="brand-container" :class="{ 'collapsed': collapsed }">
|
|
14
29
|
<div class="brand-initial">{{ brandInitial }}</div>
|
|
@@ -20,7 +35,7 @@
|
|
|
20
35
|
</div>
|
|
21
36
|
</div>
|
|
22
37
|
|
|
23
|
-
<nav class="sidebar-nav p-3">
|
|
38
|
+
<nav ref="navRef" class="sidebar-nav p-3">
|
|
24
39
|
<template v-for="(group, groupIndex) in navigation" :key="groupIndex">
|
|
25
40
|
<div
|
|
26
41
|
v-if="group.visible !== false"
|
|
@@ -34,7 +49,7 @@
|
|
|
34
49
|
class="nav-group-toggle text-uppercase small fw-semibold mb-2 px-2"
|
|
35
50
|
:aria-expanded="isGroupExpanded(groupIndex, group)"
|
|
36
51
|
:aria-controls="groupItemsId(groupIndex)"
|
|
37
|
-
@click="toggleGroup(groupIndex)"
|
|
52
|
+
@click="toggleGroup(groupKey(group, groupIndex))"
|
|
38
53
|
>
|
|
39
54
|
<span class="nav-group-toggle-label">{{ group.label }}</span>
|
|
40
55
|
<svg
|
|
@@ -78,12 +93,19 @@
|
|
|
78
93
|
>
|
|
79
94
|
<ul class="nav flex-column gap-1">
|
|
80
95
|
<li v-for="(item, itemIndex) in group.items" :key="itemIndex" class="nav-item">
|
|
96
|
+
<!--
|
|
97
|
+
@slot Renders a single navigation item link. Defaults to an anchor with optional icon, label, and badge.
|
|
98
|
+
@binding {object} item The navigation item (label, url, icon, badge, badgeColor).
|
|
99
|
+
@binding {boolean} isActive Whether this item matches the current route.
|
|
100
|
+
@binding {boolean} collapsed Whether the sidebar rail is collapsed to icons only.
|
|
101
|
+
@binding {boolean} isExpanded Whether the item's group is currently expanded.
|
|
102
|
+
-->
|
|
81
103
|
<slot
|
|
82
104
|
name="link"
|
|
83
105
|
:item="item"
|
|
84
|
-
:
|
|
106
|
+
:isActive="isActive(item.url)"
|
|
85
107
|
:collapsed="collapsed"
|
|
86
|
-
:
|
|
108
|
+
:isExpanded="isGroupExpanded(groupIndex, group)"
|
|
87
109
|
>
|
|
88
110
|
<a
|
|
89
111
|
:href="item.url"
|
|
@@ -115,6 +137,14 @@
|
|
|
115
137
|
</div>
|
|
116
138
|
</template>
|
|
117
139
|
</nav>
|
|
140
|
+
|
|
141
|
+
<div v-if="$slots.footer" class="sidebar-footer p-3">
|
|
142
|
+
<!--
|
|
143
|
+
@slot Utility/secondary links pinned to the bottom of the sidebar (help, changelog, sign-out, …), below the nav groups.
|
|
144
|
+
@binding {boolean} collapsed Whether the sidebar rail is collapsed to icons only.
|
|
145
|
+
-->
|
|
146
|
+
<slot name="footer" :collapsed="collapsed" />
|
|
147
|
+
</div>
|
|
118
148
|
</aside>
|
|
119
149
|
</template>
|
|
120
150
|
|
|
@@ -123,10 +153,15 @@ import { computed, ref, onMounted, watch, nextTick, useId } from 'vue';
|
|
|
123
153
|
import type { Navigation, NavigationGroup } from '../../types/navigation';
|
|
124
154
|
|
|
125
155
|
const props = withDefaults(defineProps<{
|
|
156
|
+
/** Grouped navigation to render: an array of groups, each with a label and items. */
|
|
126
157
|
navigation: Navigation;
|
|
158
|
+
/** The current route URL, used to highlight the matching nav item and open its group. */
|
|
127
159
|
currentUrl: string;
|
|
160
|
+
/** Collapse the rail to an icons-only strip, hiding labels and the brand title. */
|
|
128
161
|
collapsed?: boolean;
|
|
162
|
+
/** Hide the sidebar entirely (`display: none`). */
|
|
129
163
|
hidden?: boolean;
|
|
164
|
+
/** Sidebar title; its first letter is also used as the brand initial. */
|
|
130
165
|
title?: string;
|
|
131
166
|
/**
|
|
132
167
|
* Turn group headers into accordion toggles that collapse/expand their items.
|
|
@@ -149,14 +184,24 @@ const props = withDefaults(defineProps<{
|
|
|
149
184
|
});
|
|
150
185
|
|
|
151
186
|
defineEmits<{
|
|
187
|
+
/** Emitted to request toggling the sidebar's collapsed/expanded state. */
|
|
152
188
|
toggle: [];
|
|
153
189
|
}>();
|
|
154
190
|
|
|
155
191
|
const sidebarRef = ref<HTMLElement | null>(null);
|
|
192
|
+
const navRef = ref<HTMLElement | null>(null);
|
|
156
193
|
|
|
157
194
|
const uid = useId();
|
|
158
195
|
const groupItemsId = (index: number): string => `${uid}-nav-group-${index}`;
|
|
159
196
|
|
|
197
|
+
// Stable identity for a group's open/closed state. Keyed by `key` (explicit),
|
|
198
|
+
// else `label` (toggle groups always have one), else the index. Using a stable
|
|
199
|
+
// key means reordering `navigation` or flipping a group's `visible` at runtime
|
|
200
|
+
// keeps a manually-opened group's open state attached to the right group,
|
|
201
|
+
// instead of leaking to whatever now sits at that index.
|
|
202
|
+
const groupKey = (group: NavigationGroup, index: number): string =>
|
|
203
|
+
group.key ?? group.label ?? `__group_${index}`;
|
|
204
|
+
|
|
160
205
|
const brandInitial = computed(() => {
|
|
161
206
|
return props.title.charAt(0).toUpperCase();
|
|
162
207
|
});
|
|
@@ -218,18 +263,28 @@ const isGroupToggle = (group: NavigationGroup): boolean =>
|
|
|
218
263
|
const isGroupExpanded = (index: number, group: NavigationGroup): boolean => {
|
|
219
264
|
if (props.collapsed) return true;
|
|
220
265
|
if (!isGroupToggle(group)) return true;
|
|
221
|
-
return openGroups.value.has(index);
|
|
266
|
+
return openGroups.value.has(groupKey(group, index));
|
|
222
267
|
};
|
|
223
268
|
|
|
224
|
-
|
|
269
|
+
// Open groups tracked by stable key (see `groupKey`), not raw array index.
|
|
270
|
+
const openGroups = ref<Set<string>>(new Set());
|
|
225
271
|
|
|
226
|
-
|
|
227
|
-
|
|
272
|
+
// Key of the group containing the active route, or null if none.
|
|
273
|
+
const activeGroupKey = computed<string | null>(() => {
|
|
274
|
+
const index = activeGroupIndex.value;
|
|
275
|
+
if (index < 0) return null;
|
|
276
|
+
return groupKey(props.navigation[index], index);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const computeInitialOpenGroups = (): Set<string> => {
|
|
280
|
+
const next = new Set<string>();
|
|
228
281
|
if (props.autoCollapseInactiveGroups) {
|
|
229
|
-
if (
|
|
282
|
+
if (activeGroupKey.value !== null) next.add(activeGroupKey.value);
|
|
230
283
|
} else {
|
|
231
284
|
props.navigation.forEach((group, index) => {
|
|
232
|
-
if (group.visible !== false && group.collapsible !== false)
|
|
285
|
+
if (group.visible !== false && group.collapsible !== false) {
|
|
286
|
+
next.add(groupKey(group, index));
|
|
287
|
+
}
|
|
233
288
|
});
|
|
234
289
|
}
|
|
235
290
|
return next;
|
|
@@ -239,15 +294,15 @@ const computeInitialOpenGroups = (): Set<number> => {
|
|
|
239
294
|
// no post-mount height measurement, so no open/close flicker on load.
|
|
240
295
|
openGroups.value = computeInitialOpenGroups();
|
|
241
296
|
|
|
242
|
-
const toggleGroup = (
|
|
243
|
-
const wasOpen = openGroups.value.has(
|
|
297
|
+
const toggleGroup = (key: string): void => {
|
|
298
|
+
const wasOpen = openGroups.value.has(key);
|
|
244
299
|
if (props.autoCollapseInactiveGroups) {
|
|
245
|
-
openGroups.value = wasOpen ? new Set() : new Set([
|
|
300
|
+
openGroups.value = wasOpen ? new Set() : new Set([key]);
|
|
246
301
|
return;
|
|
247
302
|
}
|
|
248
303
|
const next = new Set(openGroups.value);
|
|
249
|
-
if (wasOpen) next.delete(
|
|
250
|
-
else next.add(
|
|
304
|
+
if (wasOpen) next.delete(key);
|
|
305
|
+
else next.add(key);
|
|
251
306
|
openGroups.value = next;
|
|
252
307
|
};
|
|
253
308
|
|
|
@@ -255,34 +310,34 @@ const toggleGroup = (index: number): void => {
|
|
|
255
310
|
// it (closing others); otherwise it just adds it to the open set. No-op when
|
|
256
311
|
// there is no active group (e.g. a detail page not present in the nav).
|
|
257
312
|
const openActiveGroup = (): void => {
|
|
258
|
-
if (!props.collapsibleGroups ||
|
|
313
|
+
if (!props.collapsibleGroups || activeGroupKey.value === null) return;
|
|
259
314
|
if (props.autoCollapseInactiveGroups) {
|
|
260
|
-
openGroups.value = new Set([
|
|
315
|
+
openGroups.value = new Set([activeGroupKey.value]);
|
|
261
316
|
} else {
|
|
262
317
|
const next = new Set(openGroups.value);
|
|
263
|
-
next.add(
|
|
318
|
+
next.add(activeGroupKey.value);
|
|
264
319
|
openGroups.value = next;
|
|
265
320
|
}
|
|
266
321
|
};
|
|
267
322
|
|
|
268
323
|
const scrollToActiveItem = async (smooth = false) => {
|
|
269
324
|
await nextTick();
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
325
|
+
const container = navRef.value;
|
|
326
|
+
if (!container) return;
|
|
327
|
+
|
|
328
|
+
const activeLink = container.querySelector('.nav-link.active') as HTMLElement | null;
|
|
329
|
+
if (!activeLink) return;
|
|
330
|
+
|
|
331
|
+
// Centre the active item. `offsetTop` is relative to the nav (its offset
|
|
332
|
+
// parent — `.sidebar-nav` is positioned), so it measures correctly whether or
|
|
333
|
+
// not a footer is pinned below.
|
|
334
|
+
const scrollTop =
|
|
335
|
+
activeLink.offsetTop - container.clientHeight / 2 + activeLink.offsetHeight / 2;
|
|
336
|
+
|
|
337
|
+
container.scrollTo({
|
|
338
|
+
top: scrollTop,
|
|
339
|
+
behavior: smooth ? 'smooth' : 'instant',
|
|
340
|
+
});
|
|
286
341
|
};
|
|
287
342
|
|
|
288
343
|
// Scroll to active item on initial mount (instant, no animation)
|
|
@@ -291,9 +346,14 @@ onMounted(() => {
|
|
|
291
346
|
});
|
|
292
347
|
|
|
293
348
|
// Client-side route change: open the newly active group, then scroll to it.
|
|
349
|
+
// Scrolling into a group that was collapsed kicks off its 0.2s expand, so the
|
|
350
|
+
// first scroll centres against a still-growing group; re-centre once it settles.
|
|
294
351
|
watch(() => props.currentUrl, () => {
|
|
295
352
|
openActiveGroup();
|
|
296
353
|
scrollToActiveItem(true);
|
|
354
|
+
if (typeof window !== 'undefined') {
|
|
355
|
+
window.setTimeout(() => scrollToActiveItem(true), 250);
|
|
356
|
+
}
|
|
297
357
|
});
|
|
298
358
|
|
|
299
359
|
// The active group can change without a currentUrl change — e.g. navigation
|
|
@@ -312,17 +372,25 @@ watch(activeGroupIndex, () => {
|
|
|
312
372
|
position: sticky;
|
|
313
373
|
top: 0;
|
|
314
374
|
height: 100vh;
|
|
315
|
-
|
|
316
|
-
|
|
375
|
+
/* Column layout: fixed header, scrolling nav, pinned footer. */
|
|
376
|
+
display: flex;
|
|
377
|
+
flex-direction: column;
|
|
378
|
+
overflow: hidden;
|
|
317
379
|
flex-shrink: 0;
|
|
318
380
|
}
|
|
319
381
|
|
|
320
382
|
.sidebar-header {
|
|
321
383
|
display: flex;
|
|
322
384
|
align-items: center;
|
|
385
|
+
flex-shrink: 0;
|
|
323
386
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
324
387
|
}
|
|
325
388
|
|
|
389
|
+
.sidebar-footer {
|
|
390
|
+
flex-shrink: 0;
|
|
391
|
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
392
|
+
}
|
|
393
|
+
|
|
326
394
|
.brand-container {
|
|
327
395
|
display: flex;
|
|
328
396
|
align-items: center;
|
|
@@ -346,6 +414,15 @@ watch(activeGroupIndex, () => {
|
|
|
346
414
|
}
|
|
347
415
|
|
|
348
416
|
.sidebar-nav {
|
|
417
|
+
/* The nav is the scroll region (header + footer stay put). `position:
|
|
418
|
+
relative` makes it the offset parent for scroll-to-active measurements.
|
|
419
|
+
`min-height: 0` lets this flex item shrink below its content height so
|
|
420
|
+
`overflow-y: auto` actually scrolls (without it, a flex item's default
|
|
421
|
+
`min-height: auto` keeps it at content height and it overflows the rail). */
|
|
422
|
+
position: relative;
|
|
423
|
+
flex: 1 1 auto;
|
|
424
|
+
min-height: 0;
|
|
425
|
+
overflow-y: auto;
|
|
349
426
|
overflow-x: hidden;
|
|
350
427
|
}
|
|
351
428
|
|
|
@@ -354,6 +431,15 @@ watch(activeGroupIndex, () => {
|
|
|
354
431
|
letter-spacing: 0.5px;
|
|
355
432
|
}
|
|
356
433
|
|
|
434
|
+
/* When collapsible groups are on, give the static (non-collapsible) group
|
|
435
|
+
labels the same height as the toggle headers so a sidebar mixing both keeps
|
|
436
|
+
an even vertical rhythm. */
|
|
437
|
+
.sidebar-collapsible-groups .nav-group-label {
|
|
438
|
+
display: flex;
|
|
439
|
+
align-items: center;
|
|
440
|
+
min-height: 2.5rem;
|
|
441
|
+
}
|
|
442
|
+
|
|
357
443
|
/* Collapsible group toggle header */
|
|
358
444
|
.nav-group-toggle {
|
|
359
445
|
display: flex;
|
|
@@ -419,12 +505,21 @@ watch(activeGroupIndex, () => {
|
|
|
419
505
|
display: grid;
|
|
420
506
|
grid-template-rows: 0fr;
|
|
421
507
|
opacity: 0;
|
|
422
|
-
|
|
508
|
+
/* `inert` (set in the template) removes closed links from tab order in modern
|
|
509
|
+
browsers; `visibility: hidden` is the fallback that also removes them where
|
|
510
|
+
`inert` is unsupported. Delay the visibility flip until after the fade so
|
|
511
|
+
the close animation still plays. */
|
|
512
|
+
visibility: hidden;
|
|
513
|
+
transition: grid-template-rows 0.2s ease, opacity 0.2s ease,
|
|
514
|
+
visibility 0s linear 0.2s;
|
|
423
515
|
}
|
|
424
516
|
|
|
425
517
|
.nav-group-open > .nav-group-items--collapsible {
|
|
426
518
|
grid-template-rows: 1fr;
|
|
427
519
|
opacity: 1;
|
|
520
|
+
visibility: visible;
|
|
521
|
+
transition: grid-template-rows 0.2s ease, opacity 0.2s ease,
|
|
522
|
+
visibility 0s linear 0s;
|
|
428
523
|
}
|
|
429
524
|
|
|
430
525
|
.nav-group-items--collapsible > .nav {
|
|
@@ -470,21 +565,21 @@ watch(activeGroupIndex, () => {
|
|
|
470
565
|
padding: 0.625rem;
|
|
471
566
|
}
|
|
472
567
|
|
|
473
|
-
/* Custom scrollbar */
|
|
474
|
-
.
|
|
568
|
+
/* Custom scrollbar (the nav is the scroll region) */
|
|
569
|
+
.sidebar-nav::-webkit-scrollbar {
|
|
475
570
|
width: 6px;
|
|
476
571
|
}
|
|
477
572
|
|
|
478
|
-
.
|
|
573
|
+
.sidebar-nav::-webkit-scrollbar-track {
|
|
479
574
|
background: rgba(0, 0, 0, 0.1);
|
|
480
575
|
}
|
|
481
576
|
|
|
482
|
-
.
|
|
577
|
+
.sidebar-nav::-webkit-scrollbar-thumb {
|
|
483
578
|
background: rgba(255, 255, 255, 0.2);
|
|
484
579
|
border-radius: 3px;
|
|
485
580
|
}
|
|
486
581
|
|
|
487
|
-
.
|
|
582
|
+
.sidebar-nav::-webkit-scrollbar-thumb:hover {
|
|
488
583
|
background: rgba(255, 255, 255, 0.3);
|
|
489
584
|
}
|
|
490
585
|
|