@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.
- package/dist/components/extended/DXCurrencyInput.vue.d.ts +2 -2
- package/dist/components/extended/DXDashboard.vue.d.ts +14 -6
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +12 -3
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +21 -1
- package/dist/components/extended/DXStackingTable.vue.d.ts +25 -0
- package/dist/dashboard-for-laravel.js +9967 -9739
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/style.css +1 -1
- package/dist/types/navigation.d.ts +8 -0
- package/dist/utils/pageScrollLock.d.ts +9 -0
- package/docs/public/api-reference.json +81 -7
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +1 -1
- package/resources/css/theme.scss +452 -36
- package/resources/js/components/base/DModal.vue +20 -2
- package/resources/js/components/extended/DXCurrencyInput.vue +3 -1
- package/resources/js/components/extended/DXDashboard.vue +236 -43
- package/resources/js/components/extended/DXDashboardNavbar.vue +23 -13
- package/resources/js/components/extended/DXDashboardSidebar.vue +103 -29
- package/resources/js/components/extended/DXStackingTable.vue +264 -0
- package/resources/js/composables/useContainerWidth.ts +12 -3
- package/resources/js/index.ts +2 -0
- package/resources/js/types/navigation.ts +9 -0
- package/resources/js/utils/pageScrollLock.ts +27 -0
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
<template>
|
|
11
11
|
<aside
|
|
12
12
|
ref="sidebarRef"
|
|
13
|
-
class="dashboard-sidebar
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
501
|
-
|
|
502
|
-
|
|
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:
|
|
689
|
+
background: var(--dx-sidebar-scrollbar-track);
|
|
616
690
|
}
|
|
617
691
|
|
|
618
692
|
.sidebar-nav::-webkit-scrollbar-thumb {
|
|
619
|
-
background:
|
|
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:
|
|
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 ` ` 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 ` ` 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 =
|
|
285
|
-
|
|
286
|
-
|
|
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
|
);
|
package/resources/js/index.ts
CHANGED
|
@@ -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
|
+
};
|