@omnitend/dashboard-for-laravel 0.9.2 → 0.11.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,7 +1,7 @@
1
1
  # Documentation Map
2
2
 
3
3
  > Auto-generated hierarchical overview of all documentation
4
- > Last updated: 2026-07-04T11:54:16.017Z
4
+ > Last updated: 2026-07-04T13:10:37.517Z
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.9.2",
3
+ "version": "0.11.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",
@@ -1,5 +1,7 @@
1
1
  <!--
2
- DXBasicForm — deprecated alias of DXForm.
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,15 +45,22 @@
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>
42
52
 
43
53
  <!-- Page Content -->
44
54
  <main class="dashboard-main p-4">
45
- <DContainer fluid>
55
+ <!-- Fluid: full-width, left-aligned content (for wide tables / admin
56
+ pages). Default: a centred reading-width column. -->
57
+ <DContainer v-if="fluid" fluid :class="contentClass">
58
+ <!-- @slot Default slot for the main page content. Full-width when `fluid`, otherwise a centred reading-width column. -->
59
+ <slot />
60
+ </DContainer>
61
+ <DContainer v-else fluid>
46
62
  <DRow class="justify-content-center">
47
- <DCol cols="12" xl="10">
63
+ <DCol cols="12" xl="10" :class="contentClass">
48
64
  <slot />
49
65
  </DCol>
50
66
  </DRow>
@@ -78,6 +94,16 @@ interface Props {
78
94
  /** Page title shown in navbar */
79
95
  pageTitle?: string;
80
96
 
97
+ /**
98
+ * Render the page content full-width and left-aligned instead of the default
99
+ * centred, reading-width (`col-xl-10`) column. Use for data-heavy admin pages
100
+ * (wide tables).
101
+ */
102
+ fluid?: boolean;
103
+
104
+ /** Extra class(es) applied to the content container/column. */
105
+ contentClass?: string;
106
+
81
107
  /** User object for navbar dropdown */
82
108
  user?: { name: string; email: string } | null;
83
109
 
@@ -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 class="h-100">
4
- <DRow class="h-100 align-items-center">
5
- <DCol class="d-flex align-items-center gap-3">
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,21 +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
- <div class="flex-grow-1 d-flex justify-content-center">
31
- <slot name="search" />
32
- </div>
33
- </DCol>
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>
34
47
 
35
- <DCol cols="auto" class="d-flex align-items-center gap-3">
48
+ <div class="dashboard-navbar__end d-flex align-items-center gap-3">
36
49
  <!-- Page-level primary actions, right-aligned next to the user menu -->
37
50
  <div
38
51
  v-if="$slots.actions"
39
52
  class="dashboard-navbar__actions d-flex align-items-center gap-2"
40
53
  >
41
- <slot name="actions" :page-title="pageTitle" />
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" />
42
59
  </div>
43
60
 
61
+ <!--
62
+ @slot Replaces the entire user menu (the default avatar dropdown).
63
+ @binding {object} user The signed-in user.
64
+ -->
44
65
  <slot name="user-menu" :user="user">
45
66
  <DDropdown
46
67
  v-if="user"
@@ -50,6 +71,10 @@
50
71
  no-caret
51
72
  >
52
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
+ -->
53
78
  <slot name="user-icon" :initial="getUserInitial(user)">
54
79
  <div class="user-avatar">
55
80
  {{ getUserInitial(user) }}
@@ -57,29 +82,33 @@
57
82
  </slot>
58
83
  </template>
59
84
 
85
+ <!--
86
+ @slot Items for the default user dropdown menu.
87
+ @binding {object} user The signed-in user.
88
+ -->
60
89
  <slot name="user-menu-items" :user="user" />
61
90
  </DDropdown>
62
91
  </slot>
63
- </DCol>
64
- </DRow>
92
+ </div>
93
+ </div>
65
94
  </DContainer>
66
95
  </header>
67
96
  </template>
68
97
 
69
98
  <script setup lang="ts">
70
99
  import DContainer from "../base/DContainer.vue";
71
- import DRow from "../base/DRow.vue";
72
- import DCol from "../base/DCol.vue";
73
100
  import DButton from "../base/DButton.vue";
74
101
  import DDropdown from "../base/DDropdown.vue";
75
102
 
76
103
  withDefaults(
77
104
  defineProps<{
105
+ /** The signed-in user shown in the avatar dropdown. `null` hides the menu. */
78
106
  user?: {
79
107
  name: string;
80
108
  email: string;
81
109
  [key: string]: any;
82
110
  } | null;
111
+ /** Page title shown at the left of the navbar (hidden below the `md` breakpoint). */
83
112
  pageTitle?: string;
84
113
  }>(),
85
114
  {
@@ -89,6 +118,7 @@ withDefaults(
89
118
  );
90
119
 
91
120
  defineEmits<{
121
+ /** Emitted when the hamburger menu button is clicked. */
92
122
  toggleSidebar: [];
93
123
  }>();
94
124
 
@@ -105,6 +135,40 @@ const getUserInitial = (user: { name: string } | null) => {
105
135
  z-index: 1000;
106
136
  }
107
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
+
108
172
  .user-avatar {
109
173
  width: 32px;
110
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
- :is-active="isActive(item.url)"
106
+ :isActive="isActive(item.url)"
85
107
  :collapsed="collapsed"
86
- :is-expanded="isGroupExpanded(groupIndex, group)"
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
- const openGroups = ref<Set<number>>(new Set());
269
+ // Open groups tracked by stable key (see `groupKey`), not raw array index.
270
+ const openGroups = ref<Set<string>>(new Set());
225
271
 
226
- const computeInitialOpenGroups = (): Set<number> => {
227
- const next = new Set<number>();
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 (activeGroupIndex.value >= 0) next.add(activeGroupIndex.value);
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) next.add(index);
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 = (index: number): void => {
243
- const wasOpen = openGroups.value.has(index);
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([index]);
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(index);
250
- else next.add(index);
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 || activeGroupIndex.value < 0) return;
313
+ if (!props.collapsibleGroups || activeGroupKey.value === null) return;
259
314
  if (props.autoCollapseInactiveGroups) {
260
- openGroups.value = new Set([activeGroupIndex.value]);
315
+ openGroups.value = new Set([activeGroupKey.value]);
261
316
  } else {
262
317
  const next = new Set(openGroups.value);
263
- next.add(activeGroupIndex.value);
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
- if (!sidebarRef.value) return;
271
-
272
- const activeLink = sidebarRef.value.querySelector('.nav-link.active') as HTMLElement;
273
- if (activeLink) {
274
- const sidebar = sidebarRef.value;
275
- const linkRect = activeLink.getBoundingClientRect();
276
- const sidebarRect = sidebar.getBoundingClientRect();
277
-
278
- // Calculate the position to scroll to (center the active item)
279
- const scrollTop = activeLink.offsetTop - (sidebarRect.height / 2) + (linkRect.height / 2);
280
-
281
- sidebar.scrollTo({
282
- top: scrollTop,
283
- behavior: smooth ? 'smooth' : 'instant',
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
- overflow-y: auto;
316
- overflow-x: hidden;
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
- transition: grid-template-rows 0.2s ease, opacity 0.2s ease;
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
- .dashboard-sidebar::-webkit-scrollbar {
568
+ /* Custom scrollbar (the nav is the scroll region) */
569
+ .sidebar-nav::-webkit-scrollbar {
475
570
  width: 6px;
476
571
  }
477
572
 
478
- .dashboard-sidebar::-webkit-scrollbar-track {
573
+ .sidebar-nav::-webkit-scrollbar-track {
479
574
  background: rgba(0, 0, 0, 0.1);
480
575
  }
481
576
 
482
- .dashboard-sidebar::-webkit-scrollbar-thumb {
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
- .dashboard-sidebar::-webkit-scrollbar-thumb:hover {
582
+ .sidebar-nav::-webkit-scrollbar-thumb:hover {
488
583
  background: rgba(255, 255, 255, 0.3);
489
584
  }
490
585