@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.
Files changed (54) hide show
  1. package/dist/components/base/DFormSelect.vue.d.ts +1 -1
  2. package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
  3. package/dist/components/extended/DXCurrencyInput.vue.d.ts +2 -2
  4. package/dist/components/extended/DXDashboard.vue.d.ts +14 -6
  5. package/dist/components/extended/DXDashboardNavbar.vue.d.ts +12 -3
  6. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +21 -1
  7. package/dist/components/extended/DXForm.vue.d.ts +66 -1
  8. package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
  9. package/dist/components/extended/DXFormField.vue.d.ts +3 -0
  10. package/dist/components/extended/DXStackingTable.vue.d.ts +25 -0
  11. package/dist/components/extended/DXTable.vue.d.ts +20 -0
  12. package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
  13. package/dist/composables/useAsyncOptions.d.ts +14 -1
  14. package/dist/composables/useForm.d.ts +26 -0
  15. package/dist/dashboard-for-laravel.js +12002 -11223
  16. package/dist/dashboard-for-laravel.js.map +1 -1
  17. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  18. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/style.css +1 -1
  21. package/dist/types/index.d.ts +12 -0
  22. package/dist/types/navigation.d.ts +8 -0
  23. package/dist/utils/formErrorTargets.d.ts +86 -0
  24. package/dist/utils/pageScrollLock.d.ts +9 -0
  25. package/docs/public/api-reference.json +223 -9
  26. package/docs/public/docs-map.md +4 -2
  27. package/docs/public/llms.txt +4 -2
  28. package/package.json +9 -8
  29. package/resources/css/theme.scss +508 -36
  30. package/resources/js/components/base/DFormSelect.vue +5 -2
  31. package/resources/js/components/base/DModal.vue +20 -2
  32. package/resources/js/components/extended/DXChoiceField.vue +42 -26
  33. package/resources/js/components/extended/DXCurrencyInput.vue +3 -1
  34. package/resources/js/components/extended/DXDashboard.vue +236 -43
  35. package/resources/js/components/extended/DXDashboardNavbar.vue +23 -13
  36. package/resources/js/components/extended/DXDashboardSidebar.vue +103 -29
  37. package/resources/js/components/extended/DXField.vue +15 -9
  38. package/resources/js/components/extended/DXForm.vue +676 -28
  39. package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
  40. package/resources/js/components/extended/DXFormField.vue +11 -2
  41. package/resources/js/components/extended/DXRepeater.vue +30 -4
  42. package/resources/js/components/extended/DXStackingTable.vue +264 -0
  43. package/resources/js/components/extended/DXTable.vue +36 -4
  44. package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
  45. package/resources/js/composables/useAsyncOptions.ts +30 -5
  46. package/resources/js/composables/useContainerWidth.ts +12 -3
  47. package/resources/js/composables/useForm.ts +196 -18
  48. package/resources/js/composables/useResourceEditor.ts +140 -107
  49. package/resources/js/index.ts +17 -0
  50. package/resources/js/types/index.ts +13 -0
  51. package/resources/js/types/navigation.ts +9 -0
  52. package/resources/js/utils/formErrorTargets.ts +317 -0
  53. package/resources/js/utils/pageScrollLock.ts +27 -0
  54. 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 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 {
@@ -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
- <div v-if="field.span" :class="field.class || 'mb-3'">
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-ready="searchableOptionsReady"
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(() => props.field, effectiveModel);
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,