@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.
@@ -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-06-30T11:46:51.421Z",
2
+ "generated": "2026-07-04T04:12:03.097Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.6.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
  }
@@ -1,7 +1,7 @@
1
1
  # Documentation Map
2
2
 
3
3
  > Auto-generated hierarchical overview of all documentation
4
- > Last updated: 2026-06-30T11:46:51.484Z
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.7.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.6.0",
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.22.0",
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.0.3",
79
- "@vitest/browser-playwright": "^4.0.3",
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.0.3",
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"
@@ -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 v-if="group.visible !== false" class="nav-group mb-3">
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
- <ul class="nav flex-column gap-1">
38
- <li v-for="(item, itemIndex) in group.items" :key="itemIndex" class="nav-item">
39
- <slot
40
- name="link"
41
- :item="item"
42
- :is-active="isActive(item.url)"
43
- :collapsed="collapsed"
44
- >
45
- <a
46
- :href="item.url"
47
- class="nav-link d-flex align-items-center gap-2 rounded"
48
- :class="{
49
- 'active': isActive(item.url),
50
- 'justify-content-center': collapsed
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
- <component
54
- v-if="item.icon"
55
- :is="item.icon"
56
- class="nav-icon"
57
- style="width: 20px; height: 20px;"
58
- />
59
- <span v-if="!collapsed" class="nav-label">{{ item.label }}</span>
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
- {{ item.badge }}
66
- </span>
67
- </a>
68
- </slot>
69
- </li>
70
- </ul>
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
- const isActive = (url: string): boolean => {
104
- // Normalize URLs for comparison (remove trailing slash, lowercase)
105
- const normalizeUrl = (u: string) => u.toLowerCase().replace(/\/$/, '');
106
- return normalizeUrl(props.currentUrl) === normalizeUrl(url);
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
- // Watch for URL changes (client-side routing) and scroll smoothly
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
  }