@omnitend/dashboard-for-laravel 0.42.2 → 0.43.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.
@@ -7,9 +7,17 @@
7
7
  any `navbar-*` slot to the navbar, stripping the prefix.
8
8
  -->
9
9
  <template>
10
- <div class="dashboard-layout d-flex" :data-dashboard-id="dashboardId">
10
+ <div
11
+ ref="layoutRef"
12
+ class="dashboard-layout d-flex"
13
+ :class="`dashboard-layout--sidebar-${sidebarVariant}`"
14
+ :data-dashboard-id="dashboardId"
15
+ >
11
16
  <!-- Sidebar -->
12
17
  <DXDashboardSidebar
18
+ :id="sidebarId"
19
+ ref="sidebarComponent"
20
+ phone-menu
13
21
  :navigation="navigation"
14
22
  :current-url="currentUrl"
15
23
  :title="title"
@@ -17,7 +25,10 @@
17
25
  :hidden="hidden"
18
26
  :collapsible-groups="collapsibleGroups"
19
27
  :auto-collapse-inactive-groups="autoCollapseInactiveGroups"
28
+ :variant="sidebarVariant"
20
29
  @toggle="toggleSidebar"
30
+ @close="closePhoneMenu"
31
+ @navigate="onSidebarNavigate"
21
32
  >
22
33
  <!-- Dynamically forward all sidebar-* slots by stripping the prefix -->
23
34
  <template
@@ -39,8 +50,13 @@
39
50
  </template>
40
51
  </DXDashboardSidebar>
41
52
 
42
- <!-- Main Content Area -->
43
- <div class="dashboard-content flex-grow-1">
53
+ <!-- Main Content Area: the navbar and the page. Inert behind the open
54
+ phone menu, which covers both, so neither Tab, a click-through nor a
55
+ screen reader reaches them: focus is contained the way a native modal
56
+ <dialog> contains it, Tab moving between the sidebar's controls and
57
+ the browser's own. Modals teleported to <body> are outside and
58
+ unaffected. -->
59
+ <div class="dashboard-content flex-grow-1" :inert="phoneMenuOpen ? true : undefined">
44
60
  <!-- Top Navbar -->
45
61
  <DXDashboardNavbar
46
62
  :page-title="pageTitle"
@@ -48,6 +64,8 @@
48
64
  :search-align="searchAlign"
49
65
  :actions-on-mobile="actionsOnMobile"
50
66
  :user-menu-label="userMenuLabel"
67
+ :sidebar-expanded="!hidden"
68
+ :sidebar-id="sidebarId"
51
69
  @toggle-sidebar="toggleSidebar"
52
70
  >
53
71
  <!-- Dynamically forward all navbar-* slots by stripping the prefix -->
@@ -96,13 +114,14 @@
96
114
  </template>
97
115
 
98
116
  <script setup lang="ts">
99
- import { ref, computed, useSlots } from 'vue';
117
+ import { ref, computed, useSlots, useId, watch, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue';
100
118
  import DXDashboardSidebar from './DXDashboardSidebar.vue';
101
119
  import DXDashboardNavbar from './DXDashboardNavbar.vue';
102
120
  import DContainer from '../base/DContainer.vue';
103
121
  import DRow from '../base/DRow.vue';
104
122
  import DCol from '../base/DCol.vue';
105
- import type { Navigation, NavbarActionsOnMobile, NavbarSearchAlign } from '../../types/navigation';
123
+ import { acquirePageScrollLock, releasePageScrollLock } from '../../utils/pageScrollLock';
124
+ import type { Navigation, NavbarActionsOnMobile, NavbarSearchAlign, SidebarVariant } from '../../types/navigation';
106
125
 
107
126
  const slots = useSlots();
108
127
 
@@ -173,6 +192,14 @@ interface Props {
173
192
  */
174
193
  autoCollapseInactiveGroups?: boolean;
175
194
 
195
+ /**
196
+ * Sidebar colour scheme, forwarded to DXDashboardSidebar as `variant`:
197
+ * `'light'` (default: white pane, tinted group headers, green active item,
198
+ * Poppins) or `'dark'` (the navy rail). Colours come from the
199
+ * `--dx-sidebar-*` custom properties, so either can be rebranded in CSS.
200
+ */
201
+ sidebarVariant?: SidebarVariant;
202
+
176
203
  /** LocalStorage key for sidebar state persistence */
177
204
  storageKey?: string;
178
205
 
@@ -190,6 +217,7 @@ const props = withDefaults(defineProps<Props>(), {
190
217
  user: null,
191
218
  collapsibleGroups: false,
192
219
  autoCollapseInactiveGroups: true,
220
+ sidebarVariant: 'light',
193
221
  storageKey: 'dashboard-sidebar-hidden',
194
222
  dashboardId: '',
195
223
  contentMaxWidth: '1140px',
@@ -224,66 +252,231 @@ const navbarSlots = computed(() => {
224
252
  return result;
225
253
  });
226
254
 
227
- // Initialize sidebar visibility from localStorage
228
- const getInitialHiddenState = (): boolean => {
229
- // Skip during SSR - no access to localStorage or document
230
- if (typeof window === 'undefined') {
231
- return !props.dashboardId; // Default: hidden for global, visible for scoped
255
+ /**
256
+ * Phone width: below Bootstrap's `sm` breakpoint (576px by default), the same
257
+ * breakpoint at which DModal goes full screen. There the open sidebar covers
258
+ * the whole viewport (theme.scss), so it behaves as a menu rather than a rail:
259
+ * it always starts closed, closes when a link is followed or on Escape, holds
260
+ * keyboard focus while open, and its state is never written to (or read from)
261
+ * `storageKey`, which keeps the desktop preference. Consumer apps navigate
262
+ * with full page loads, so restoring an "open" preference on a phone reopened
263
+ * the menu over every new page.
264
+ *
265
+ * The width comes from the theme, which publishes `breakpoint-max(sm)` as
266
+ * `--dx-dashboard-phone-max-width` on :root, so a theme compiled with its own
267
+ * `$grid-breakpoints` moves this behaviour and the full-screen styles
268
+ * together. 575.98px if the theme's CSS is not loaded.
269
+ */
270
+ const PHONE_MAX_WIDTH_PROPERTY = '--dx-dashboard-phone-max-width';
271
+ const DEFAULT_PHONE_MAX_WIDTH = '575.98px';
272
+
273
+ const readPhoneMaxWidth = (): string => {
274
+ const published = getComputedStyle(document.documentElement)
275
+ .getPropertyValue(PHONE_MAX_WIDTH_PROPERTY)
276
+ .trim();
277
+ return published !== '' ? published : DEFAULT_PHONE_MAX_WIDTH;
278
+ };
279
+
280
+ // The query for the width last read. Re-read whenever the phone check runs
281
+ // (setup, mount, toggle, close, Escape), not once: a theme stylesheet that
282
+ // loads after the dashboard mounts must still take effect.
283
+ let phoneMediaQuery: MediaQueryList | null = null;
284
+ let phoneMediaQueryWidth = '';
285
+ let listeningForPhoneChanges = false;
286
+
287
+ const isPhone = ref(false);
288
+
289
+ const refreshPhoneQuery = (): void => {
290
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
291
+ const width = readPhoneMaxWidth();
292
+ if (phoneMediaQuery === null || width !== phoneMediaQueryWidth) {
293
+ if (listeningForPhoneChanges) {
294
+ phoneMediaQuery?.removeEventListener('change', onPhoneMediaChange);
295
+ }
296
+ phoneMediaQuery = window.matchMedia(`(max-width: ${width})`);
297
+ phoneMediaQueryWidth = width;
298
+ if (listeningForPhoneChanges) {
299
+ phoneMediaQuery.addEventListener('change', onPhoneMediaChange);
300
+ }
232
301
  }
302
+ isPhone.value = phoneMediaQuery.matches;
303
+ };
304
+
305
+ // Default with nothing stored: hidden for global instances (docs), visible for
306
+ // scoped instances (examples). Also what a server renders, having no storage.
307
+ const defaultHidden = (): boolean => !props.dashboardId;
233
308
 
309
+ // The remembered desktop preference, or the default when none is stored.
310
+ const readDesktopHidden = (): boolean => {
234
311
  try {
235
312
  const savedHidden = localStorage.getItem(props.storageKey);
236
313
  if (savedHidden !== null) {
237
- const isHidden = JSON.parse(savedHidden);
238
-
239
- // If no dashboard ID (global instance), update HTML class for SSR compatibility
240
- if (!props.dashboardId) {
241
- if (isHidden) {
242
- document.documentElement.classList.remove('sidebar-visible');
243
- } else {
244
- document.documentElement.classList.add('sidebar-visible');
245
- }
246
- }
247
-
248
- return isHidden;
314
+ return JSON.parse(savedHidden);
249
315
  }
250
316
  } catch (error) {
251
317
  console.error('Error loading sidebar state:', error);
252
318
  }
319
+ return defaultHidden();
320
+ };
253
321
 
254
- // Default: hidden for global instances (docs), visible for scoped instances (examples)
255
- if (!props.dashboardId) {
256
- document.documentElement.classList.remove('sidebar-visible');
257
- return true;
258
- }
259
- return false; // Show sidebar in scoped instances by default
322
+ // The global instance (no dashboardId) mirrors visibility onto <html> so
323
+ // layout CSS can follow it before hydration (see the docs' inline script).
324
+ const syncHtmlClass = (isHidden: boolean): void => {
325
+ if (props.dashboardId || typeof document === 'undefined') return;
326
+ document.documentElement.classList.toggle('sidebar-visible', !isHidden);
260
327
  };
261
328
 
262
- const hidden = ref(getInitialHiddenState());
329
+ // The state this browser should show: on a phone the menu is closed whatever
330
+ // is stored.
331
+ const getClientHiddenState = (): boolean => (isPhone.value ? true : readDesktopHidden());
332
+
333
+ // The first render uses what a server renders (`defaultHidden`), on the client
334
+ // too, so hydration sees the server's own markup and the state agrees with it;
335
+ // the client's state is applied on mount, before the browser paints a
336
+ // client-only mount. The global <html> class, which layout CSS reads before
337
+ // hydration, is set from the client's state straight away.
338
+ if (typeof window !== 'undefined') {
339
+ refreshPhoneQuery();
340
+ syncHtmlClass(getClientHiddenState());
341
+ }
342
+ const hidden = ref(defaultHidden());
263
343
 
264
- const toggleSidebar = () => {
265
- hidden.value = !hidden.value;
344
+ const isMounted = ref(false);
345
+ // False while a <KeepAlive> holds the dashboard out of the page.
346
+ const isActive = ref(true);
266
347
 
267
- // Skip during SSR
268
- if (typeof window === 'undefined') return;
348
+ /** The full-screen phone menu is open (and this dashboard is on the page). */
349
+ const phoneMenuOpen = computed(
350
+ () => isMounted.value && isActive.value && isPhone.value && !hidden.value,
351
+ );
269
352
 
270
- // If no dashboard ID (global instance), update HTML class
271
- if (!props.dashboardId) {
272
- if (hidden.value) {
273
- document.documentElement.classList.remove('sidebar-visible');
274
- } else {
275
- document.documentElement.classList.add('sidebar-visible');
276
- }
277
- }
353
+ const sidebarId = `${useId()}-sidebar`;
354
+ const layoutRef = ref<HTMLElement | null>(null);
355
+ const sidebarComponent = ref<InstanceType<typeof DXDashboardSidebar> | null>(null);
278
356
 
279
- // Save to localStorage
357
+ const setHidden = (isHidden: boolean): void => {
358
+ hidden.value = isHidden;
359
+ if (typeof window === 'undefined') return;
360
+ syncHtmlClass(isHidden);
361
+
362
+ // Only desktop toggles are remembered; a phone's open/closed menu is not a
363
+ // preference.
364
+ if (isPhone.value) return;
280
365
  try {
281
- localStorage.setItem(props.storageKey, JSON.stringify(hidden.value));
366
+ localStorage.setItem(props.storageKey, JSON.stringify(isHidden));
282
367
  } catch (error) {
283
368
  console.error('Error saving sidebar state:', error);
284
369
  }
285
370
  };
286
371
 
372
+ const toggleSidebar = () => {
373
+ refreshPhoneQuery();
374
+ setHidden(!hidden.value);
375
+ };
376
+
377
+ // Close the phone menu without touching the stored desktop preference.
378
+ const closePhoneMenu = (): void => {
379
+ refreshPhoneQuery();
380
+ if (isPhone.value && !hidden.value) {
381
+ setHidden(true);
382
+ }
383
+ };
384
+
385
+ // A link in the sidebar was followed: on a phone, get the menu out of the way
386
+ // (for client-side routing, and so it is gone before a slow full page load).
387
+ const onSidebarNavigate = (): void => {
388
+ closePhoneMenu();
389
+ };
390
+
391
+ const sidebarElement = (): HTMLElement | null =>
392
+ (sidebarComponent.value?.$el as HTMLElement | undefined) ?? null;
393
+
394
+ const onKeydown = (event: KeyboardEvent): void => {
395
+ if (event.key !== 'Escape') return;
396
+ // An Escape aimed at something outside the dashboard (a modal opened from
397
+ // the menu, teleported to <body>) is that thing's, not the menu's.
398
+ const target = event.target;
399
+ if (target instanceof Node && target !== document.body && !layoutRef.value?.contains(target)) return;
400
+ refreshPhoneQuery();
401
+ if (phoneMenuOpen.value) {
402
+ closePhoneMenu();
403
+ }
404
+ };
405
+
406
+ const sidebarToggle = (): HTMLElement | null =>
407
+ layoutRef.value?.querySelector<HTMLElement>(`[aria-controls="${sidebarId}"]`) ?? null;
408
+
409
+ // Opening the phone menu moves focus into it (its close button); closing it
410
+ // hands focus back to the navbar toggle, unless focus has already gone
411
+ // somewhere else on purpose. With no toggle to return to (a consumer removed
412
+ // it), nothing is focused: the hidden menu has already lost focus to <body>.
413
+ // After the DOM update, so the navbar is no longer inert when focus returns.
414
+ watch(
415
+ phoneMenuOpen,
416
+ (isOpen, wasOpen) => {
417
+ const sidebar = sidebarElement();
418
+ if (isOpen) {
419
+ sidebar?.querySelector<HTMLElement>('.sidebar-close')?.focus();
420
+ return;
421
+ }
422
+ if (!wasOpen || !hidden.value || !layoutRef.value?.isConnected) return;
423
+ const active = document.activeElement;
424
+ const focusWasInMenu =
425
+ active === null || active === document.body || (sidebar !== null && sidebar.contains(active));
426
+ if (focusWasInMenu) {
427
+ sidebarToggle()?.focus();
428
+ }
429
+ },
430
+ { flush: 'post' },
431
+ );
432
+
433
+ // Crossing the breakpoint (rotation, a resized window): entering phone width
434
+ // closes the menu; leaving it restores the remembered desktop preference.
435
+ const onPhoneMediaChange = (event: MediaQueryListEvent): void => {
436
+ isPhone.value = event.matches;
437
+ hidden.value = event.matches ? true : readDesktopHidden();
438
+ syncHtmlClass(hidden.value);
439
+ };
440
+
441
+ // While the phone menu is open the page behind it must not scroll: a class on
442
+ // <html> (styled in theme.scss, below `sm` only), held per dashboard so one
443
+ // closing or unmounting cannot unlock another's open menu.
444
+ const scrollLockOwner = {};
445
+ watch(phoneMenuOpen, (isOpen) => {
446
+ if (isOpen) {
447
+ acquirePageScrollLock(scrollLockOwner);
448
+ } else {
449
+ releasePageScrollLock(scrollLockOwner);
450
+ }
451
+ });
452
+
453
+ onMounted(() => {
454
+ refreshPhoneQuery();
455
+ listeningForPhoneChanges = true;
456
+ phoneMediaQuery?.addEventListener('change', onPhoneMediaChange);
457
+ isMounted.value = true;
458
+ hidden.value = getClientHiddenState();
459
+ document.addEventListener('keydown', onKeydown);
460
+ });
461
+
462
+ // A <KeepAlive> keeps the dashboard alive off the page: whatever took its
463
+ // place must scroll, and the menu should not reappear open on return.
464
+ onDeactivated(() => {
465
+ closePhoneMenu();
466
+ isActive.value = false;
467
+ });
468
+
469
+ onActivated(() => {
470
+ isActive.value = true;
471
+ });
472
+
473
+ onBeforeUnmount(() => {
474
+ phoneMediaQuery?.removeEventListener('change', onPhoneMediaChange);
475
+ listeningForPhoneChanges = false;
476
+ document.removeEventListener('keydown', onKeydown);
477
+ releasePageScrollLock(scrollLockOwner);
478
+ });
479
+
287
480
  defineExpose({
288
481
  /**
289
482
  * Show/hide the sidebar (same as clicking the navbar's hamburger). The
@@ -6,10 +6,12 @@
6
6
  with a `navbar-` prefix.
7
7
  -->
8
8
  <template>
9
- <header ref="headerRef" class="dashboard-navbar border-bottom">
9
+ <header ref="headerRef" class="dashboard-navbar">
10
10
  <DContainer fluid>
11
- <!-- Flex bar that wraps: below `md` the search drops to its own
12
- full-width row beneath the toggle/title/user-menu row. -->
11
+ <!-- Flex bar: toggle (and title from `md` up), search filling the
12
+ middle, user menu, all on one row at every width. Below `md` the
13
+ actions wrap to a row of their own (theme.scss, "Dashboard navbar
14
+ layout"). -->
13
15
  <div ref="barRef" class="dashboard-navbar__bar d-flex flex-wrap align-items-center gap-3">
14
16
  <div class="dashboard-navbar__start d-flex align-items-center gap-3">
15
17
  <DButton
@@ -17,6 +19,8 @@
17
19
  class="text-dark p-0"
18
20
  @click="$emit('toggleSidebar')"
19
21
  aria-label="Toggle sidebar"
22
+ :aria-expanded="sidebarExpanded === undefined ? undefined : String(sidebarExpanded)"
23
+ :aria-controls="sidebarId || undefined"
20
24
  >
21
25
  <!-- @slot Custom hamburger/menu icon. Defaults to a three-line SVG. -->
22
26
  <slot name="menu-icon">
@@ -43,7 +47,7 @@
43
47
  class="dashboard-navbar__search d-flex"
44
48
  :class="searchAlignClass"
45
49
  >
46
- <!-- @slot Search input or component. Aligned per `searchAlign` within its region (inline in the bar from `md` up; its own full-width row below). -->
50
+ <!-- @slot Search input or component. Fills the middle of the bar at every width, aligned per `searchAlign`. The region is a size container named `dx-navbar-search`; a `<kbd>` hint inside it is hidden when it is narrower than 16rem. -->
47
51
  <slot name="search" />
48
52
  </div>
49
53
 
@@ -132,9 +136,8 @@ const props = withDefaults(
132
136
  pageTitle?: string;
133
137
  /**
134
138
  * Horizontal alignment of the search slot content within its region
135
- * (`"start"` = flush left, `"center"` = centred). Applies at every size:
136
- * the region sits inline after the title from `md` up, and is its own
137
- * full-width row below.
139
+ * (`"start"` = flush left, `"center"` = centred). The region sits inline
140
+ * between the toggle/title and the user menu at every width.
138
141
  */
139
142
  searchAlign?: NavbarSearchAlign;
140
143
  /**
@@ -148,6 +151,14 @@ const props = withDefaults(
148
151
  * announces the trigger as the avatar's initial ("J") rather than as a menu.
149
152
  */
150
153
  userMenuLabel?: string;
154
+ /**
155
+ * Whether the sidebar the toggle controls is showing, exposed as the
156
+ * toggle's `aria-expanded`. Left unset, the toggle carries none.
157
+ * DXDashboard sets it.
158
+ */
159
+ sidebarExpanded?: boolean;
160
+ /** `id` of the sidebar the toggle controls (its `aria-controls`). */
161
+ sidebarId?: string;
151
162
  }>(),
152
163
  {
153
164
  user: null,
@@ -155,6 +166,8 @@ const props = withDefaults(
155
166
  searchAlign: "start",
156
167
  actionsOnMobile: "wrap",
157
168
  userMenuLabel: "User menu",
169
+ sidebarExpanded: undefined,
170
+ sidebarId: "",
158
171
  },
159
172
  );
160
173
 
@@ -267,12 +280,9 @@ const getUserInitial = (user: { name: string } | null) => {
267
280
  z-index: 1000;
268
281
  }
269
282
 
270
- /* The bar's vertical padding — and the single-row content budget derived from
271
- it — is owned by theme.scss (`$dashboard-navbar-bar-padding-y`), so the
272
- geometry has one source (#102). Only the floor lives here. */
273
- .dashboard-navbar__bar {
274
- min-height: 3.5rem;
275
- }
283
+ /* The bar's vertical padding, its height floor and the single-row content
284
+ budget are all owned by theme.scss, derived from `--dx-dashboard-header-height`,
285
+ so the geometry has one source (#102). */
276
286
 
277
287
  /*
278
288
  * The bar's responsive LAYOUT (the order/flex rules and the `md` switch) lives