@omnitend/dashboard-for-laravel 0.42.2 → 0.44.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/base/DFormSelect.vue.d.ts +1 -1
- package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
- 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/DXForm.vue.d.ts +66 -1
- package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
- package/dist/components/extended/DXFormField.vue.d.ts +3 -0
- package/dist/components/extended/DXStackingTable.vue.d.ts +25 -0
- package/dist/components/extended/DXTable.vue.d.ts +20 -0
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
- package/dist/composables/useAsyncOptions.d.ts +14 -1
- package/dist/composables/useForm.d.ts +26 -0
- package/dist/dashboard-for-laravel.js +12002 -11223
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/types/navigation.d.ts +8 -0
- package/dist/utils/formErrorTargets.d.ts +86 -0
- package/dist/utils/pageScrollLock.d.ts +9 -0
- package/docs/public/api-reference.json +223 -9
- package/docs/public/docs-map.md +4 -2
- package/docs/public/llms.txt +4 -2
- package/package.json +9 -8
- package/resources/css/theme.scss +508 -36
- package/resources/js/components/base/DFormSelect.vue +5 -2
- package/resources/js/components/base/DModal.vue +20 -2
- package/resources/js/components/extended/DXChoiceField.vue +42 -26
- 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/DXField.vue +15 -9
- package/resources/js/components/extended/DXForm.vue +676 -28
- package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
- package/resources/js/components/extended/DXFormField.vue +11 -2
- package/resources/js/components/extended/DXRepeater.vue +30 -4
- package/resources/js/components/extended/DXStackingTable.vue +264 -0
- package/resources/js/components/extended/DXTable.vue +36 -4
- package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
- package/resources/js/composables/useAsyncOptions.ts +30 -5
- package/resources/js/composables/useContainerWidth.ts +12 -3
- package/resources/js/composables/useForm.ts +196 -18
- package/resources/js/composables/useResourceEditor.ts +140 -107
- package/resources/js/index.ts +17 -0
- package/resources/js/types/index.ts +13 -0
- package/resources/js/types/navigation.ts +9 -0
- package/resources/js/utils/formErrorTargets.ts +317 -0
- package/resources/js/utils/pageScrollLock.ts +27 -0
- package/scripts/mcp-server.mjs +58 -6
|
@@ -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 {
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
<template>
|
|
14
14
|
<!-- Full-width span field: delegate entirely to the #span slot, bypassing
|
|
15
15
|
the label and built-in control (and the shell). -->
|
|
16
|
-
|
|
16
|
+
<!-- `data-dx-field-key` (the field's data path: `name`, or
|
|
17
|
+
`lines.0.price` inside a repeater) is how DXForm finds a field to
|
|
18
|
+
scroll to and focus after a failed submit (#194). -->
|
|
19
|
+
<div v-if="field.span" :class="field.class || 'mb-3'" :data-dx-field-key="valuePath">
|
|
17
20
|
<!--
|
|
18
21
|
@slot Replaces the entire field with custom full-width content when `field.span` is set, bypassing the label and built-in control.
|
|
19
22
|
@binding {FieldDefinition} field The field definition being rendered.
|
|
@@ -34,6 +37,7 @@
|
|
|
34
37
|
control that goes inside is chosen by field.type in the default slot. -->
|
|
35
38
|
<DXFieldShell
|
|
36
39
|
v-else
|
|
40
|
+
:data-dx-field-key="valuePath"
|
|
37
41
|
:field="field"
|
|
38
42
|
:form="form"
|
|
39
43
|
:error-key="errorKey"
|
|
@@ -264,7 +268,7 @@
|
|
|
264
268
|
:state="fieldState"
|
|
265
269
|
:disabled="isDisabled || isReadonly || isPlaintext"
|
|
266
270
|
:control-props="controlPropsWithGuards"
|
|
267
|
-
:options-
|
|
271
|
+
:options-pending="optionsPending"
|
|
268
272
|
@update:model-value="setValue"
|
|
269
273
|
/>
|
|
270
274
|
|
|
@@ -780,12 +784,6 @@ const isPlaintext = computed(() => resolveMaybe(props.field.plaintext) ?? false)
|
|
|
780
784
|
|
|
781
785
|
const passwordRevealed = ref(false);
|
|
782
786
|
|
|
783
|
-
// See the template: the searchable select can't render before its options
|
|
784
|
-
// resolve, or it shows the raw id instead of the option's label.
|
|
785
|
-
const searchableOptionsReady = computed(
|
|
786
|
-
() => !props.field.optionsLoader || loadedOptions.value !== null,
|
|
787
|
-
);
|
|
788
|
-
|
|
789
787
|
const isRevealablePassword = computed(
|
|
790
788
|
() =>
|
|
791
789
|
props.field.type === "password" &&
|
|
@@ -856,7 +854,15 @@ const datalistId = useId();
|
|
|
856
854
|
|
|
857
855
|
// ————————————————— async options
|
|
858
856
|
|
|
859
|
-
const { loadedOptions } = useAsyncOptions(
|
|
857
|
+
const { loadedOptions, firstLoadSettled } = useAsyncOptions(
|
|
858
|
+
() => props.field,
|
|
859
|
+
effectiveModel,
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
// The first load has not settled: nothing to label the value with yet. Once
|
|
863
|
+
// it has (even by failing, which leaves the static options), a reload never
|
|
864
|
+
// makes the field pending again: it keeps what it already shows.
|
|
865
|
+
const optionsPending = computed(() => !firstLoadSettled.value);
|
|
860
866
|
|
|
861
867
|
const resolvedOptions = computed<FieldOption[] | undefined>(
|
|
862
868
|
() => loadedOptions.value ?? props.field.options,
|