@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.
@@ -10,12 +10,16 @@
10
10
  <template>
11
11
  <aside
12
12
  ref="sidebarRef"
13
- class="dashboard-sidebar text-white"
13
+ class="dashboard-sidebar"
14
14
  :class="{
15
+ [`dashboard-sidebar--${variant}`]: true,
15
16
  'sidebar-collapsed': collapsed,
16
17
  'sidebar-hidden': hidden,
17
- 'sidebar-collapsible-groups': collapsibleGroups && !collapsed
18
+ 'sidebar-collapsible-groups': collapsibleGroups && !collapsed,
19
+ 'dashboard-sidebar--phone-menu': phoneMenu,
20
+ 'dashboard-sidebar--phone-menu-open': phoneMenuOpen
18
21
  }"
22
+ @click="onLinkClick"
19
23
  >
20
24
  <div class="sidebar-header p-3">
21
25
  <div class="d-flex align-items-center justify-content-between">
@@ -33,6 +37,19 @@
33
37
  </div>
34
38
  </slot>
35
39
  </div>
40
+ <!-- With `phoneMenu`, below `sm` the open sidebar covers the whole
41
+ screen, navbar toggle included, so it carries its own way out, at
42
+ the far end of the header row. Hidden from `sm` up, where the navbar
43
+ toggle stays in view. Not rendered without `phoneMenu`: nothing
44
+ would answer its `close`. -->
45
+ <button
46
+ v-if="phoneMenu"
47
+ type="button"
48
+ class="sidebar-close btn-close d-sm-none flex-shrink-0 ms-auto"
49
+ :class="{ 'btn-close-white': variant === 'dark' }"
50
+ aria-label="Close menu"
51
+ @click="$emit('close')"
52
+ />
36
53
  </div>
37
54
 
38
55
  <nav ref="navRef" class="sidebar-nav px-3 pb-3 pt-2">
@@ -46,7 +63,7 @@
46
63
  <button
47
64
  v-if="isGroupToggle(group)"
48
65
  type="button"
49
- class="nav-group-toggle fw-semibold mb-2 px-2"
66
+ class="nav-group-toggle"
50
67
  :aria-expanded="isGroupExpanded(groupIndex, group)"
51
68
  :aria-controls="groupItemsId(groupIndex)"
52
69
  @click="toggleGroup(groupKey(group, groupIndex))"
@@ -75,14 +92,14 @@
75
92
  <!-- Static group label (non-collapsible, expanded sidebar) -->
76
93
  <div
77
94
  v-else-if="group.label && !collapsed"
78
- class="nav-group-label fw-semibold mb-2 px-2"
95
+ class="nav-group-label"
79
96
  >
80
97
  {{ group.label }}
81
98
  </div>
82
99
 
83
100
  <!-- Divider shown in place of the label when the sidebar rail is collapsed -->
84
101
  <div v-if="group.label && collapsed" class="nav-group-divider">
85
- <hr class="my-2 border-secondary" />
102
+ <hr class="my-2" />
86
103
  </div>
87
104
 
88
105
  <div
@@ -150,7 +167,7 @@
150
167
 
151
168
  <script setup lang="ts">
152
169
  import { computed, ref, onMounted, watch, nextTick, useId } from 'vue';
153
- import type { Navigation, NavigationGroup } from '../../types/navigation';
170
+ import type { Navigation, NavigationGroup, SidebarVariant } from '../../types/navigation';
154
171
 
155
172
  const props = withDefaults(defineProps<{
156
173
  /** Grouped navigation to render: an array of groups, each with a label and items. */
@@ -175,20 +192,68 @@ const props = withDefaults(defineProps<{
175
192
  * `false`: all groups start open and toggle independently.
176
193
  */
177
194
  autoCollapseInactiveGroups?: boolean;
195
+ /**
196
+ * Colour scheme: `'light'` (default) or `'dark'`. Colours come from the
197
+ * `--dx-sidebar-*` custom properties (see the theming guide), so either
198
+ * scheme can be rebranded in CSS.
199
+ */
200
+ variant?: SidebarVariant;
201
+ /**
202
+ * Below `sm`, show the open sidebar as a full-screen menu over the page,
203
+ * with a close button (which emits `close`), instead of a rail beside it;
204
+ * hidden there until opened. The owner must answer `close`, Escape and
205
+ * `navigate`: DXDashboard sets this and does. Off by default, so a
206
+ * standalone sidebar keeps its rail on phones.
207
+ */
208
+ phoneMenu?: boolean;
178
209
  }>(), {
179
210
  collapsed: false,
180
211
  hidden: false,
181
212
  title: 'Dashboard',
182
213
  collapsibleGroups: false,
183
214
  autoCollapseInactiveGroups: true,
215
+ variant: 'light',
216
+ phoneMenu: false,
184
217
  });
185
218
 
186
- defineEmits<{
219
+ const emit = defineEmits<{
187
220
  /** Emitted to request toggling the sidebar's collapsed/expanded state. */
188
221
  toggle: [];
222
+ /** Emitted by the close button shown below `sm`, where the open sidebar covers the screen. */
223
+ close: [];
224
+ /**
225
+ * Emitted when a link anywhere in the sidebar is followed (a plain click,
226
+ * not one that opens a new tab): the navigation, including links rendered
227
+ * through the `link` slot, and the `brand` and `footer` slots. DXDashboard
228
+ * uses it to close the full-screen menu on a phone.
229
+ */
230
+ navigate: [];
189
231
  }>();
190
232
 
233
+ // Delegated from the whole sidebar, so `link`, `brand` and `footer` slot
234
+ // anchors count too. A modified click opens a new tab or window and leaves
235
+ // this page where it is. A cancelled click still counts: a client-side
236
+ // router's link (Inertia's <Link>) cancels the browser navigation and routes
237
+ // itself.
238
+ const onLinkClick = (event: MouseEvent): void => {
239
+ if (event.button !== 0) return;
240
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
241
+ const target = event.target as Element | null;
242
+ if (target?.closest('a[href]')) {
243
+ emit('navigate');
244
+ }
245
+ };
246
+
191
247
  const sidebarRef = ref<HTMLElement | null>(null);
248
+
249
+ // The open phone menu's class waits for mount, so a server-rendered page
250
+ // (which cannot know the width) never paints an open menu on a phone before
251
+ // hydration, and hydration sees the same classes the server sent.
252
+ const isMounted = ref(false);
253
+ onMounted(() => {
254
+ isMounted.value = true;
255
+ });
256
+ const phoneMenuOpen = computed(() => props.phoneMenu && isMounted.value && !props.hidden);
192
257
  const navRef = ref<HTMLElement | null>(null);
193
258
 
194
259
  const uid = useId();
@@ -377,6 +442,15 @@ onMounted(() => {
377
442
  scrollToActiveItem(false);
378
443
  });
379
444
 
445
+ // A hidden sidebar has no layout to scroll, so the mount-time scroll does
446
+ // nothing; centre the active item whenever it is shown (opening the phone
447
+ // menu, or a dashboard that restores its visibility after mount).
448
+ watch(() => props.hidden, (isHidden) => {
449
+ if (!isHidden) {
450
+ scrollToActiveItem(false);
451
+ }
452
+ });
453
+
380
454
  // Client-side route change: open the newly active group, then scroll to it.
381
455
  // Scrolling into a group that was collapsed kicks off its 0.2s expand, so the
382
456
  // first scroll centres against a still-growing group; re-centre once it settles.
@@ -415,14 +489,17 @@ watch(activeGroupIndex, () => {
415
489
  display: flex;
416
490
  align-items: center;
417
491
  flex-shrink: 0;
418
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
492
+ border-bottom: var(--dx-sidebar-header-border-width) solid var(--dx-sidebar-separator-color);
419
493
  }
420
494
 
421
495
  .sidebar-footer {
422
496
  flex-shrink: 0;
423
- border-top: 1px solid rgba(255, 255, 255, 0.1);
497
+ border-top: 1px solid var(--dx-sidebar-separator-color);
424
498
  }
425
499
 
500
+ /* Colours, typeface and weights come from the `--dx-sidebar-*` tokens in
501
+ theme.scss (per variant), not from here, so a consumer can rebrand them. */
502
+
426
503
  .brand-container {
427
504
  display: flex;
428
505
  align-items: center;
@@ -473,6 +550,16 @@ watch(activeGroupIndex, () => {
473
550
  font-size: 0.875rem;
474
551
  }
475
552
 
553
+ /* Padding and the gap below live here rather than in `px-2`/`mb-2`
554
+ utilities, whose `!important` would stop the light variant reshaping the
555
+ headers into pills (theme.scss). */
556
+ .nav-group-label,
557
+ .nav-group-toggle {
558
+ padding-left: 0.5rem;
559
+ padding-right: 0.5rem;
560
+ margin-bottom: 0.5rem;
561
+ }
562
+
476
563
  /* When collapsible groups are on, give the static (non-collapsible) group
477
564
  labels the same height as the toggle headers so a sidebar mixing both keeps
478
565
  an even vertical rhythm. */
@@ -495,26 +582,17 @@ watch(activeGroupIndex, () => {
495
582
  padding-top: 0.25rem;
496
583
  padding-bottom: 0.25rem;
497
584
  font-size: 0.875rem;
498
- background: transparent;
499
585
  border: 0;
500
- /* No `color` here on purpose: theme.scss sets `.nav-group-toggle` to
501
- $navbar-dark-color so the toggle matches the static .nav-group-label.
502
- A scoped `color` would override that (equal specificity, later source order). */
586
+ /* No `color` or `background-color` here on purpose: theme.scss sets both
587
+ from the `--dx-sidebar-group-*` tokens (also for :hover and
588
+ :focus-visible), and a scoped declaration would override them (equal
589
+ specificity, later source order). */
503
590
  cursor: pointer;
504
591
  text-align: left;
505
592
  border-radius: var(--bs-border-radius, 0.375rem);
506
593
  transition: background-color 0.2s ease;
507
594
  }
508
595
 
509
- .nav-group-toggle:hover {
510
- background-color: rgba(255, 255, 255, 0.08);
511
- }
512
-
513
- .nav-group-toggle:focus-visible {
514
- outline: 2px solid rgba(255, 255, 255, 0.5);
515
- outline-offset: 2px;
516
- }
517
-
518
596
  .nav-group-toggle-label {
519
597
  overflow: hidden;
520
598
  text-overflow: ellipsis;
@@ -590,10 +668,6 @@ watch(activeGroupIndex, () => {
590
668
  white-space: nowrap;
591
669
  }
592
670
 
593
- :deep(.nav-link.active) {
594
- font-weight: 500;
595
- }
596
-
597
671
  :deep(.nav-icon) {
598
672
  flex-shrink: 0;
599
673
  }
@@ -612,16 +686,16 @@ watch(activeGroupIndex, () => {
612
686
  }
613
687
 
614
688
  .sidebar-nav::-webkit-scrollbar-track {
615
- background: rgba(0, 0, 0, 0.1);
689
+ background: var(--dx-sidebar-scrollbar-track);
616
690
  }
617
691
 
618
692
  .sidebar-nav::-webkit-scrollbar-thumb {
619
- background: rgba(255, 255, 255, 0.2);
693
+ background: var(--dx-sidebar-scrollbar-thumb);
620
694
  border-radius: 3px;
621
695
  }
622
696
 
623
697
  .sidebar-nav::-webkit-scrollbar-thumb:hover {
624
- background: rgba(255, 255, 255, 0.3);
698
+ background: var(--dx-sidebar-scrollbar-thumb-hover);
625
699
  }
626
700
 
627
701
  .sidebar-hidden {
@@ -0,0 +1,264 @@
1
+ <template>
2
+ <!--
3
+ The wrapper is what gets measured, never the table: a table that is
4
+ overflowing is WIDER than the space it has, so measuring it would never
5
+ report "too narrow". The wrapper is also a horizontal scroll container
6
+ (like Bootstrap's `.table-responsive`, which it replaces), which makes its
7
+ own width the available width even inside a flex row.
8
+ -->
9
+ <div
10
+ ref="containerRef"
11
+ class="dx-stacking-table"
12
+ :class="{ 'dx-stacking-table--stacked': isStacked }"
13
+ >
14
+ <!--
15
+ @slot The consumer's own `<table>`, as the wrapper's direct child.
16
+ Receives `stacked` for the rare markup that must differ per layout.
17
+ -->
18
+ <slot :stacked="isStacked" />
19
+ </div>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ /**
24
+ * DXStackingTable — a hand-built `<table>` that turns into one card per body
25
+ * row when the space it has (not the window) falls below `stackBelow`.
26
+ *
27
+ * Built for editable tables inside forms and modals (an order's lines: name,
28
+ * price input, quantity input, total), where inputs run off a phone's edge. The
29
+ * consumer keeps writing ordinary table markup; this wrapper measures itself
30
+ * with `useContainerWidth`, toggles `.dx-stacking-table--stacked`, and gives
31
+ * every body and footer cell its column's header text as a label (written to
32
+ * `data-dx-stack-label`, shown by theme.scss as the cell's `::before`). The
33
+ * stacked styling is global theme CSS, because slot content cannot see a
34
+ * component's scoped styles.
35
+ *
36
+ * Per-cell controls (all inert in the wide layout):
37
+ * - `data-label="…"` on a `th` or a cell overrides the derived label.
38
+ * - `.dx-stack-span` on a cell: no label, full card width (a card title).
39
+ * Cells spanning several columns (`colspan`) get no label and span anyway.
40
+ * - `.dx-stack-hide` on a cell: not shown when stacked. Empty cells with no
41
+ * label (a footer's `&nbsp;` filler) are hidden automatically.
42
+ * - `.dx-stack-continue` on a `tr`: joins the previous row's card (a note row
43
+ * under a line) instead of starting a new one.
44
+ */
45
+ import { computed, onBeforeUnmount, onMounted } from "vue";
46
+ import { useContainerWidth } from "../../composables/useContainerWidth";
47
+
48
+ interface Props {
49
+ /**
50
+ * Stack when the wrapper's content width is below this many px. 576 is
51
+ * Bootstrap's `sm` breakpoint, but measured on the container.
52
+ */
53
+ stackBelow?: number;
54
+ }
55
+
56
+ const props = withDefaults(defineProps<Props>(), {
57
+ stackBelow: 576,
58
+ });
59
+
60
+ /** Attribute the derived label is written to (so a consumer `data-label` is never overwritten). */
61
+ const DERIVED_LABEL_ATTRIBUTE = "data-dx-stack-label";
62
+ /** Marks an empty cell that has no label, which the stacked layout hides. */
63
+ const EMPTY_CELL_ATTRIBUTE = "data-dx-stack-empty";
64
+ /** Header content that is not the column's visible name. */
65
+ const HIDDEN_HEADER_CONTENT =
66
+ '.visually-hidden, .visually-hidden-focusable, [aria-hidden="true"]';
67
+
68
+ const { containerRef, isBelow } = useContainerWidth({
69
+ threshold: () => props.stackBelow,
70
+ // Same band as DXForm's auto layout, for the same reason: the stacked
71
+ // layout is TALLER, so inside an `overflow:auto` ancestor it can add a
72
+ // scrollbar and change its own container's width at the boundary. 24px is
73
+ // wider than any scrollbar, so a crossing holds until the space genuinely
74
+ // changes.
75
+ hysteresis: 24,
76
+ });
77
+
78
+ /**
79
+ * Stacked until measured, like DXForm's `layout="auto"` (which renders its
80
+ * vertical layout before the first measurement and under SSR): a stacked table
81
+ * is legible at any width, a wide one guessed wrongly overflows. In a browser
82
+ * the first ResizeObserver delivery lands before the first paint, so a wide
83
+ * container does not visibly flash the cards.
84
+ */
85
+ const isStacked = computed<boolean>(() => isBelow.value);
86
+
87
+ /** The column's visible name: `data-label`, else its text minus hidden helpers. */
88
+ function headerCellLabel(headerCell: HTMLTableCellElement): string {
89
+ const explicitLabel = headerCell.getAttribute("data-label");
90
+ if (explicitLabel !== null) return explicitLabel.trim();
91
+
92
+ let text = "";
93
+ const walker = document.createTreeWalker(headerCell, NodeFilter.SHOW_TEXT);
94
+ let node = walker.nextNode();
95
+ while (node !== null) {
96
+ // Only hiding INSIDE the header cell counts; an ancestor of the whole
97
+ // table being aria-hidden says nothing about this column's name.
98
+ const hiddenWrapper =
99
+ node.parentElement?.closest(HIDDEN_HEADER_CONTENT) ?? null;
100
+ const isHidden =
101
+ hiddenWrapper !== null &&
102
+ hiddenWrapper !== headerCell &&
103
+ headerCell.contains(hiddenWrapper);
104
+ if (!isHidden) text += ` ${node.textContent ?? ""}`;
105
+ node = walker.nextNode();
106
+ }
107
+ return text.replace(/\s+/g, " ").trim();
108
+ }
109
+
110
+ /**
111
+ * The first column of every cell in a row group, accounting for cells from
112
+ * earlier rows that reach down into a row (`rowspan`) as well as `colspan`:
113
+ * the table's own grid, not each row's cell index.
114
+ */
115
+ function cellStartColumns(
116
+ rows: HTMLCollectionOf<HTMLTableRowElement>,
117
+ ): Map<HTMLTableCellElement, number> {
118
+ const startColumns = new Map<HTMLTableCellElement, number>();
119
+ /** Columns taken in each row by a cell starting in an earlier row. */
120
+ const occupiedByRow: Array<Set<number>> = Array.from(
121
+ { length: rows.length },
122
+ () => new Set<number>(),
123
+ );
124
+ Array.from(rows).forEach((row, rowIndex) => {
125
+ const occupied = occupiedByRow[rowIndex];
126
+ let column = 0;
127
+ for (const cell of Array.from(row.cells)) {
128
+ while (occupied.has(column)) column++;
129
+ startColumns.set(cell, column);
130
+ // rowspan="0" reaches to the end of the row group.
131
+ const rowSpan =
132
+ cell.rowSpan === 0 ? rows.length - rowIndex : cell.rowSpan;
133
+ const lastRow = Math.min(rowIndex + rowSpan, rows.length);
134
+ for (let spannedRow = rowIndex; spannedRow < lastRow; spannedRow++) {
135
+ for (let offset = 0; offset < cell.colSpan; offset++) {
136
+ occupiedByRow[spannedRow].add(column + offset);
137
+ }
138
+ }
139
+ column += cell.colSpan;
140
+ }
141
+ });
142
+ return startColumns;
143
+ }
144
+
145
+ /**
146
+ * One label per column: the LOWEST header cell over it, so a group heading
147
+ * ("Pricing" over Price and Quantity) gives way to the names beneath it, and a
148
+ * heading spanning every header row ("Product") names its own column.
149
+ */
150
+ function columnLabels(table: HTMLTableElement): string[] {
151
+ const headerRows = table.tHead?.rows;
152
+ if (headerRows === undefined || headerRows.length === 0) return [];
153
+ const labels: string[] = [];
154
+ // Row order, so a lower row's cell overwrites the group heading above it.
155
+ for (const [headerCell, startColumn] of cellStartColumns(headerRows)) {
156
+ const label = headerCellLabel(headerCell);
157
+ for (let offset = 0; offset < headerCell.colSpan; offset++) {
158
+ labels[startColumn + offset] = label;
159
+ }
160
+ }
161
+ return labels;
162
+ }
163
+
164
+ function setAttributeIfChanged(
165
+ element: Element,
166
+ name: string,
167
+ value: string | null,
168
+ ): void {
169
+ if (value === null) {
170
+ if (element.hasAttribute(name)) element.removeAttribute(name);
171
+ return;
172
+ }
173
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value);
174
+ }
175
+
176
+ function isBlank(cell: HTMLTableCellElement): boolean {
177
+ // `trim()` also strips the `&nbsp;` a footer filler cell usually holds.
178
+ return (
179
+ cell.children.length === 0 && (cell.textContent ?? "").trim() === ""
180
+ );
181
+ }
182
+
183
+ /** Label every body and footer cell from its column's header. */
184
+ function labelCells(): void {
185
+ const table = containerRef.value?.querySelector<HTMLTableElement>(
186
+ ":scope > table",
187
+ );
188
+ if (table === null || table === undefined) return;
189
+
190
+ const labels = columnLabels(table);
191
+ const rowGroups: HTMLTableSectionElement[] = [...Array.from(table.tBodies)];
192
+ if (table.tFoot !== null) rowGroups.push(table.tFoot);
193
+
194
+ for (const rowGroup of rowGroups) {
195
+ for (const [cell, startColumn] of cellStartColumns(rowGroup.rows)) {
196
+ // A cell spanning several columns belongs to none of them.
197
+ const derivedLabel =
198
+ cell.colSpan === 1 ? labels[startColumn] ?? "" : "";
199
+ setAttributeIfChanged(
200
+ cell,
201
+ DERIVED_LABEL_ATTRIBUTE,
202
+ derivedLabel === "" ? null : derivedLabel,
203
+ );
204
+ const hasLabel =
205
+ derivedLabel !== "" || cell.hasAttribute("data-label");
206
+ setAttributeIfChanged(
207
+ cell,
208
+ EMPTY_CELL_ATTRIBUTE,
209
+ !hasLabel && isBlank(cell) ? "" : null,
210
+ );
211
+ }
212
+ }
213
+ }
214
+
215
+ // Rows come and go (v-for), and header text can change: relabel on any change
216
+ // to the table's structure or text. Only the attributes labelling reads are
217
+ // watched, so our own `data-dx-stack-*` writes never re-trigger this.
218
+ let mutationObserver: MutationObserver | null = null;
219
+
220
+ /** Attributes whose change can alter a label or a cell's column. */
221
+ const STRUCTURE_ATTRIBUTES = ["data-label", "colspan", "rowspan"];
222
+ /**
223
+ * Attributes that hide header helper text (HIDDEN_HEADER_CONTENT). They count
224
+ * only inside a `thead`: a body cell's classes change all the time (a row's
225
+ * state, a validation style) and say nothing about labels.
226
+ */
227
+ const HEADER_VISIBILITY_ATTRIBUTES = ["class", "aria-hidden"];
228
+
229
+ function changesLabels(mutation: MutationRecord): boolean {
230
+ if (mutation.type !== "attributes") return true;
231
+ if (STRUCTURE_ATTRIBUTES.includes(mutation.attributeName ?? "")) return true;
232
+ return (
233
+ mutation.target instanceof Element &&
234
+ mutation.target.closest("thead") !== null
235
+ );
236
+ }
237
+
238
+ onMounted(() => {
239
+ labelCells();
240
+ if (typeof MutationObserver === "undefined" || containerRef.value === null) {
241
+ return;
242
+ }
243
+ mutationObserver = new MutationObserver((mutations) => {
244
+ if (mutations.some(changesLabels)) labelCells();
245
+ });
246
+ mutationObserver.observe(containerRef.value, {
247
+ childList: true,
248
+ subtree: true,
249
+ characterData: true,
250
+ attributes: true,
251
+ attributeFilter: [
252
+ ...STRUCTURE_ATTRIBUTES,
253
+ ...HEADER_VISIBILITY_ATTRIBUTES,
254
+ ],
255
+ });
256
+ });
257
+
258
+ onBeforeUnmount(() => {
259
+ mutationObserver?.disconnect();
260
+ mutationObserver = null;
261
+ });
262
+
263
+ defineExpose({ isStacked });
264
+ </script>
@@ -270,10 +270,17 @@ export function useContainerWidth(
270
270
  // express without mutating during evaluation. With `hysteresis: 0` it is
271
271
  // exactly `width < threshold`. The watcher is `sync` so `isBelow` is never
272
272
  // observed one tick behind `width`.
273
+ //
274
+ // The band only applies to a state that came from a MEASUREMENT. Before
275
+ // the first one, `isBelow` is the `initialWidth` guess (true by default),
276
+ // and holding that guess through the band would leave a container that
277
+ // measures just above the threshold (say 590 against 576 + 24) on the
278
+ // narrow layout until it next grows: it never crossed anything.
273
279
  const initialThreshold = resolveThreshold();
274
280
  const isBelow = ref<boolean>(
275
281
  initialThreshold === undefined ? false : startWidth < initialThreshold,
276
282
  );
283
+ let isBelowCameFromMeasurement = false;
277
284
  watch(
278
285
  [width, () => resolveThreshold()],
279
286
  ([currentWidth, currentThreshold]) => {
@@ -281,9 +288,11 @@ export function useContainerWidth(
281
288
  isBelow.value = false;
282
289
  return;
283
290
  }
284
- isBelow.value = isBelow.value
285
- ? currentWidth < currentThreshold + hysteresis
286
- : currentWidth < currentThreshold;
291
+ isBelow.value =
292
+ isBelow.value && isBelowCameFromMeasurement
293
+ ? currentWidth < currentThreshold + hysteresis
294
+ : currentWidth < currentThreshold;
295
+ if (hasMeasured.value) isBelowCameFromMeasurement = true;
287
296
  },
288
297
  { flush: "sync" },
289
298
  );
@@ -20,6 +20,7 @@ export { default as DXStatCard } from "./components/extended/DXStatCard.vue";
20
20
  // (#142) so chart.js / vue-chartjs stay genuinely optional peers — the main
21
21
  // entry no longer references them. See resources/js/charts.ts.
22
22
  export { default as DXRepeater } from "./components/extended/DXRepeater.vue";
23
+ export { default as DXStackingTable } from "./components/extended/DXStackingTable.vue";
23
24
  /**
24
25
  * @deprecated Use `DXForm`. `DXBasicForm` is a thin wrapper around `DXForm`
25
26
  * (a flat form is just `DXForm` without a `tabs` prop) that logs a one-time
@@ -202,4 +203,5 @@ export type {
202
203
  Navigation,
203
204
  NavbarSearchAlign,
204
205
  NavbarActionsOnMobile,
206
+ SidebarVariant,
205
207
  } from "./types/navigation";
@@ -38,6 +38,15 @@ export type Navigation = NavigationGroup[];
38
38
  */
39
39
  export type NavbarSearchAlign = "start" | "center";
40
40
 
41
+ /**
42
+ * Colour scheme of the dashboard sidebar: `"light"` (the default: a white pane,
43
+ * tinted group headers, a green active item, Poppins) or `"dark"` (the navy
44
+ * rail dfl shipped before). Every colour comes from a `--dx-sidebar-*` custom
45
+ * property, so either can be rebranded in CSS. Shared by DXDashboard (which
46
+ * forwards it as `sidebarVariant`) and DXDashboardSidebar (`variant`).
47
+ */
48
+ export type SidebarVariant = "light" | "dark";
49
+
41
50
  /**
42
51
  * What the navbar actions slot does below the `md` breakpoint: `"wrap"` moves
43
52
  * it to its own full-width row (the bar grows), `"hide"` removes it entirely
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The class on <html> that stops the page scrolling behind DXDashboard's open
3
+ * phone menu (styled in theme.scss, below `sm` only).
4
+ */
5
+ export const PAGE_SCROLL_LOCK_CLASS = 'dx-dashboard-menu-open';
6
+
7
+ // Every owner currently holding the lock. Several dashboards can share a page,
8
+ // so one releasing (closing, unmounting, a <KeepAlive> deactivation) must not
9
+ // unlock the page while another's menu is still open.
10
+ const lockOwners = new Set<object>();
11
+
12
+ const syncRootClass = (): void => {
13
+ if (typeof document === 'undefined') return;
14
+ document.documentElement.classList.toggle(PAGE_SCROLL_LOCK_CLASS, lockOwners.size > 0);
15
+ };
16
+
17
+ /** Hold the page scroll lock for `owner`. Idempotent. */
18
+ export const acquirePageScrollLock = (owner: object): void => {
19
+ lockOwners.add(owner);
20
+ syncRootClass();
21
+ };
22
+
23
+ /** Release `owner`'s hold; the page unlocks once no owner holds it. Idempotent. */
24
+ export const releasePageScrollLock = (owner: object): void => {
25
+ if (!lockOwners.delete(owner)) return;
26
+ syncRootClass();
27
+ };