@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
@@ -338,6 +338,11 @@ $dx-form-label-width: 45%;
338
338
 
339
339
  $dashboard-sidebar-width: 280px;
340
340
  $dashboard-sidebar-collapsed-width: 80px;
341
+ // The height of BOTH halves of the dashboard's top edge: the sidebar header
342
+ // (brand row) and the navbar. Published as `--dx-dashboard-header-height` on
343
+ // `:root` and read by both, so their bottom edges line up by construction; a
344
+ // consumer retunes the shell's header height by overriding that ONE property
345
+ // (never by sizing the two separately, which is how a stepped corner appears).
341
346
  $dashboard-navbar-height: 64px;
342
347
 
343
348
  // The shell's one horizontal gutter: the navbar's inner padding AND the page
@@ -348,6 +353,10 @@ $dashboard-navbar-height: 64px;
348
353
  // a consumer can retune both edges together by overriding that.
349
354
  $dashboard-gutter-x: 20px;
350
355
 
356
+ // Below `sm` the gutter tightens to the full-screen modal's (Bootstrap's
357
+ // `$modal-inner-padding`, 1rem), so phone pages and phone modals share an edge.
358
+ $dashboard-gutter-x-phone: 16px;
359
+
351
360
  // The bar's vertical padding, and the single-row CONTENT BUDGET that follows
352
361
  // from it. Everything on the bar's single row — the avatar toggle, whatever a
353
362
  // consumer puts in the `actions` or `user-icon` slots — has to fit this, or the
@@ -373,10 +382,19 @@ $dashboard-navbar-border-width: 1px; // Bootstrap's $border-width
373
382
  // runtime is the more correct answer anyway, since the padding then still scales
374
383
  // with the root font size.
375
384
  $dashboard-navbar-content-height: calc(
376
- #{$dashboard-navbar-height} - #{$dashboard-navbar-border-width} -
385
+ var(--dx-dashboard-header-height) - var(--dx-dashboard-header-border-width) -
377
386
  #{$dashboard-navbar-bar-padding-y} * 2
378
387
  );
379
388
 
389
+ // The narrowest the navbar search region may get on the toggle / search /
390
+ // user-menu row before it wraps onto a row of its own, and the width below
391
+ // which a `<kbd>` shortcut hint inside it (e.g. "⌘K") is hidden to give the
392
+ // input room. The region is a size container named `dx-navbar-search`, so
393
+ // slot content can make its own container queries against it (a shorter
394
+ // placeholder, say).
395
+ $dashboard-navbar-search-min-width: 9rem;
396
+ $dashboard-navbar-search-hint-min-width: 16rem;
397
+
380
398
  // ----------------------------------------------------------------------------
381
399
  // Import Bootstrap
382
400
  // ----------------------------------------------------------------------------
@@ -636,6 +654,62 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
636
654
  }
637
655
  }
638
656
 
657
+ // DXFormErrorSummary (#194): a heading (the title, or the server's message
658
+ // when there are no field errors), then one row per validation message. The alert keeps the danger alert's subtle tint; a row
659
+ // that points at a field is a button drawn as underlined text in the alert's
660
+ // own colour (an alert-coloured link, not the brand navy), so the list reads
661
+ // as text and still says "you can click this". Global, not scoped: the rows
662
+ // are rendered inside DAlert's slot.
663
+ .dx-form-error-summary {
664
+ .dx-form-error-summary__message {
665
+ margin-bottom: 0;
666
+ font-weight: $font-weight-semibold;
667
+ }
668
+
669
+ .dx-form-error-summary__list {
670
+ margin: map.get($spacers, 2) 0 0;
671
+ padding-inline-start: 1.25rem;
672
+ }
673
+
674
+ // "Other problems": the rows no field owns, after the owned ones. One
675
+ // step lighter than the heading (semibold), still above the body text,
676
+ // and tied to the list it introduces rather than the one above it.
677
+ .dx-form-error-summary__other-title {
678
+ margin: map.get($spacers, 2) 0 0;
679
+ font-weight: $font-weight-medium;
680
+
681
+ + .dx-form-error-summary__list {
682
+ margin-top: map.get($spacers, 1);
683
+ }
684
+ }
685
+
686
+ .dx-form-error-summary__target {
687
+ padding: 0;
688
+ border: 0;
689
+ background: none;
690
+ color: inherit;
691
+ font: inherit;
692
+ text-align: start;
693
+ text-decoration: underline;
694
+ text-underline-offset: 0.15em;
695
+ cursor: pointer;
696
+ // A button is an inline-block, and the list bullet sits on the line
697
+ // box's baseline, which a baseline-aligned inline-block takes from its
698
+ // LAST line: a row that wraps had its bullet beside its second line.
699
+ vertical-align: top;
700
+
701
+ &:hover {
702
+ text-decoration-thickness: 2px;
703
+ }
704
+
705
+ &:focus-visible {
706
+ outline: var(--bs-focus-ring-width) solid currentColor;
707
+ outline-offset: 2px;
708
+ border-radius: var(--bs-border-radius-sm);
709
+ }
710
+ }
711
+ }
712
+
639
713
  // Switch-list option rows (DXField `switch-list`) are one line each: a label,
640
714
  // a switch and an optional trailing control (a notes input in the
641
715
  // `switch-list-item` slot). Centre all three in the row, instead of the
@@ -722,6 +796,23 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
722
796
 
723
797
  :root {
724
798
  --dx-dashboard-gutter-x: #{$dashboard-gutter-x};
799
+ --dx-dashboard-header-height: #{$dashboard-navbar-height};
800
+ // The top band (2026-10-06, "A2"): ONE background for the navbar AND the
801
+ // light sidebar's brand row, so the band runs the full width and the two can
802
+ // never differ, and no line under it. The dark sidebar keeps its own navy
803
+ // brand row and the line under both (`.dashboard-layout--sidebar-dark`).
804
+ --dx-dashboard-header-bg: var(--bs-light);
805
+ --dx-dashboard-header-border-width: 0px; // with a unit: it is subtracted in calc()
806
+ }
807
+
808
+ .dashboard-layout--sidebar-dark {
809
+ --dx-dashboard-header-border-width: #{$dashboard-navbar-border-width};
810
+ }
811
+
812
+ @include media-breakpoint-down(sm) {
813
+ :root {
814
+ --dx-dashboard-gutter-x: #{$dashboard-gutter-x-phone};
815
+ }
725
816
  }
726
817
 
727
818
  // Both are the `DContainer fluid` directly inside the navbar header and the
@@ -736,8 +827,103 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
736
827
  padding-right: var(--dx-dashboard-gutter-x);
737
828
  }
738
829
 
830
+ // Sidebar colour schemes (DXDashboard `sidebarVariant`, DXDashboardSidebar
831
+ // `variant`). Every colour, the typeface and the weights come from a
832
+ // `--dx-sidebar-*` custom property, so an install can rebrand either scheme in
833
+ // CSS without Sass: override on `:root` for the light default, on
834
+ // `.dashboard-sidebar--dark` for the dark one.
835
+ //
836
+ // LIGHT (the default since 0.43) follows the legacy Omni Tend admin: a white
837
+ // pane with no edge line and no line under the brand row, tinted group-header
838
+ // pills, grey links, a green active
839
+ // item, Poppins (500 links, 600 active and headers). Links are #6b7182
840
+ // (4.87:1 on white); the legacy #7c8293 is 3.84:1 and fails WCAG AA.
841
+ //
842
+ // DARK is the navy rail dfl shipped before, value for value. Its typeface and
843
+ // link size/weight are `initial` (no value), which makes those declarations
844
+ // fall back to inheriting from the page, exactly as before the tokens existed.
845
+ $dx-sidebar-bg: #ffffff !default;
846
+ $dx-sidebar-color: #121419 !default;
847
+ $dx-sidebar-edge-width: 0 !default;
848
+ $dx-sidebar-edge-color: #e6e9f0 !default;
849
+ $dx-sidebar-header-border-width: 0 !default;
850
+ $dx-sidebar-separator-color: #e6e9f0 !default;
851
+ $dx-sidebar-group-color: #121419 !default;
852
+ $dx-sidebar-group-bg: #f5f8fe !default;
853
+ $dx-sidebar-group-hover-bg: #ebf0fb !default;
854
+ $dx-sidebar-link-color: #6b7182 !default;
855
+ $dx-sidebar-link-hover-color: #363e4d !default;
856
+ $dx-sidebar-link-hover-bg: #f9f9fc !default;
857
+ $dx-sidebar-active-color: #121419 !default;
858
+ $dx-sidebar-active-bg: #c3faaa !default;
859
+ $dx-sidebar-focus-ring: #6b7182 !default;
860
+ $dx-sidebar-scrollbar-track: rgba($black, 0.04) !default;
861
+ $dx-sidebar-scrollbar-thumb: rgba($black, 0.18) !default;
862
+ $dx-sidebar-scrollbar-thumb-hover: rgba($black, 0.28) !default;
863
+ $dx-sidebar-font-family: var(--dx-font-family-display) !default;
864
+ $dx-sidebar-link-font-size: 0.9375rem !default;
865
+ $dx-sidebar-link-font-weight: 500 !default;
866
+ $dx-sidebar-active-font-weight: 600 !default;
867
+ $dx-sidebar-group-font-weight: 600 !default;
868
+
869
+ :root {
870
+ --dx-sidebar-bg: #{$dx-sidebar-bg};
871
+ --dx-sidebar-color: #{$dx-sidebar-color};
872
+ --dx-sidebar-edge-width: #{$dx-sidebar-edge-width};
873
+ --dx-sidebar-edge-color: #{$dx-sidebar-edge-color};
874
+ --dx-sidebar-header-border-width: #{$dx-sidebar-header-border-width};
875
+ --dx-sidebar-separator-color: #{$dx-sidebar-separator-color};
876
+ --dx-sidebar-group-color: #{$dx-sidebar-group-color};
877
+ --dx-sidebar-group-bg: #{$dx-sidebar-group-bg};
878
+ --dx-sidebar-group-hover-bg: #{$dx-sidebar-group-hover-bg};
879
+ --dx-sidebar-link-color: #{$dx-sidebar-link-color};
880
+ --dx-sidebar-link-hover-color: #{$dx-sidebar-link-hover-color};
881
+ --dx-sidebar-link-hover-bg: #{$dx-sidebar-link-hover-bg};
882
+ --dx-sidebar-active-color: #{$dx-sidebar-active-color};
883
+ --dx-sidebar-active-bg: #{$dx-sidebar-active-bg};
884
+ --dx-sidebar-focus-ring: #{$dx-sidebar-focus-ring};
885
+ --dx-sidebar-divider-color: rgba(var(--bs-secondary-rgb), 1);
886
+ --dx-sidebar-scrollbar-track: #{$dx-sidebar-scrollbar-track};
887
+ --dx-sidebar-scrollbar-thumb: #{$dx-sidebar-scrollbar-thumb};
888
+ --dx-sidebar-scrollbar-thumb-hover: #{$dx-sidebar-scrollbar-thumb-hover};
889
+ --dx-sidebar-font-family: #{$dx-sidebar-font-family};
890
+ --dx-sidebar-link-font-size: #{$dx-sidebar-link-font-size};
891
+ --dx-sidebar-link-font-weight: #{$dx-sidebar-link-font-weight};
892
+ --dx-sidebar-active-font-weight: #{$dx-sidebar-active-font-weight};
893
+ --dx-sidebar-group-font-weight: #{$dx-sidebar-group-font-weight};
894
+ }
895
+
896
+ .dashboard-sidebar--dark {
897
+ --dx-sidebar-bg: #{$dark};
898
+ --dx-sidebar-color: #{$white};
899
+ --dx-sidebar-edge-width: 0;
900
+ --dx-sidebar-edge-color: transparent;
901
+ --dx-sidebar-header-border-width: 1px;
902
+ --dx-sidebar-header-bg: transparent;
903
+ --dx-sidebar-separator-color: #{$navbar-dark-toggler-border-color};
904
+ --dx-sidebar-group-color: #{$navbar-dark-color};
905
+ --dx-sidebar-group-bg: transparent;
906
+ --dx-sidebar-group-hover-bg: #{rgba($white, 0.08)};
907
+ --dx-sidebar-link-color: #{$navbar-dark-hover-color};
908
+ --dx-sidebar-link-hover-color: #{$navbar-dark-active-color};
909
+ --dx-sidebar-link-hover-bg: #{rgba($white, 0.1)};
910
+ --dx-sidebar-active-color: #{$navbar-dark-active-color};
911
+ --dx-sidebar-active-bg: #{rgba($white, 0.15)};
912
+ --dx-sidebar-focus-ring: #{rgba($white, 0.5)};
913
+ --dx-sidebar-scrollbar-track: #{rgba($black, 0.1)};
914
+ --dx-sidebar-scrollbar-thumb: #{rgba($white, 0.2)};
915
+ --dx-sidebar-scrollbar-thumb-hover: #{rgba($white, 0.3)};
916
+ --dx-sidebar-font-family: initial;
917
+ --dx-sidebar-link-font-size: initial;
918
+ --dx-sidebar-link-font-weight: initial;
919
+ --dx-sidebar-active-font-weight: 500;
920
+ --dx-sidebar-group-font-weight: 600;
921
+ }
922
+
739
923
  .dashboard-sidebar {
740
- background-color: $dark;
924
+ background-color: var(--dx-sidebar-bg);
925
+ color: var(--dx-sidebar-color);
926
+ border-right: var(--dx-sidebar-edge-width) solid var(--dx-sidebar-edge-color);
741
927
  width: $dashboard-sidebar-width;
742
928
 
743
929
  &.sidebar-collapsed {
@@ -745,50 +931,157 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
745
931
  }
746
932
 
747
933
  .sidebar-header {
748
- height: $dashboard-navbar-height;
749
- border-bottom-color: $navbar-dark-toggler-border-color;
934
+ height: var(--dx-dashboard-header-height);
935
+ // Light: the navbar's band colour, read from the same token at this
936
+ // element, so a consumer override anywhere above reaches both. Dark sets
937
+ // `--dx-sidebar-header-bg: transparent` (the navy pane shows through).
938
+ background-color: var(--dx-sidebar-header-bg, var(--dx-dashboard-header-bg));
939
+ }
940
+
941
+ .sidebar-nav,
942
+ .sidebar-footer {
943
+ font-family: var(--dx-sidebar-font-family);
750
944
  }
751
945
 
752
946
  .nav-group-label,
753
947
  .nav-group-toggle {
754
- color: $navbar-dark-color;
948
+ color: var(--dx-sidebar-group-color);
949
+ font-weight: var(--dx-sidebar-group-font-weight);
755
950
  }
756
951
 
757
- .nav-group-divider {
758
- border-color: $navbar-dark-toggler-border-color;
952
+ .nav-group-toggle {
953
+ background-color: var(--dx-sidebar-group-bg);
954
+
955
+ &:hover {
956
+ background-color: var(--dx-sidebar-group-hover-bg);
957
+ }
958
+
959
+ &:focus-visible {
960
+ outline: 2px solid var(--dx-sidebar-focus-ring);
961
+ outline-offset: 2px;
962
+ }
963
+ }
964
+
965
+ .nav-group-divider hr {
966
+ border-color: var(--dx-sidebar-divider-color);
759
967
  }
760
968
 
761
969
  .nav-link {
762
- color: $navbar-dark-hover-color;
970
+ color: var(--dx-sidebar-link-color);
971
+ font-size: var(--dx-sidebar-link-font-size);
972
+ font-weight: var(--dx-sidebar-link-font-weight);
763
973
 
764
974
  &:hover {
765
- color: $navbar-dark-active-color;
766
- background-color: rgba($white, 0.1);
975
+ color: var(--dx-sidebar-link-hover-color);
976
+ background-color: var(--dx-sidebar-link-hover-bg);
977
+ }
978
+
979
+ // Bootstrap's own ring is a primary-tinted box-shadow, which disappears
980
+ // against the dark pane; an outline in the scheme's ring colour shows on
981
+ // both.
982
+ &:focus-visible {
983
+ outline: 2px solid var(--dx-sidebar-focus-ring);
984
+ outline-offset: 2px;
985
+ box-shadow: none;
767
986
  }
768
987
 
769
988
  &.active {
770
- color: $navbar-dark-active-color;
771
- background-color: rgba($white, 0.15);
772
- font-weight: 500;
989
+ color: var(--dx-sidebar-active-color);
990
+ background-color: var(--dx-sidebar-active-bg);
991
+ font-weight: var(--dx-sidebar-active-font-weight);
773
992
  }
774
993
  }
775
994
  }
776
995
 
996
+ // Light geometry: the group headers are pills (static labels too, so the
997
+ // default non-collapsible sidebar has them) and the links line up with the
998
+ // pill text. `aside` outranks the component's scoped padding and margin (same
999
+ // class count, but those land later in the stylesheet). The collapsed rail
1000
+ // keeps its centred icon padding.
1001
+ //
1002
+ // The gap under a pill is on its first item rather than the pill itself:
1003
+ // inside a closed collapsible group that item is clipped away with the rest,
1004
+ // so closed pills sit exactly the group gap (1rem) apart, as in the legacy
1005
+ // admin, instead of 1rem plus the header's own margin.
1006
+ aside.dashboard-sidebar--light {
1007
+ .nav-group-label,
1008
+ .nav-group-toggle {
1009
+ display: flex;
1010
+ align-items: center;
1011
+ min-height: 2.2rem;
1012
+ margin-bottom: 0;
1013
+ padding-left: 1rem;
1014
+ padding-right: 0.9rem;
1015
+ background-color: var(--dx-sidebar-group-bg);
1016
+ border-radius: $border-radius;
1017
+ }
1018
+
1019
+ .nav-group-label + .nav-group-items > .nav > .nav-item:first-child,
1020
+ .nav-group-toggle + .nav-group-items > .nav > .nav-item:first-child {
1021
+ margin-top: 0.25rem;
1022
+ }
1023
+
1024
+ &:not(.sidebar-collapsed) .nav-link {
1025
+ padding-left: 1rem;
1026
+ border-radius: $border-radius;
1027
+ }
1028
+ }
1029
+
1030
+ // Phones (below `sm`, where DModal also goes full screen): the sidebar
1031
+ // DXDashboard owns (`phoneMenu`, class `dashboard-sidebar--phone-menu`) is a
1032
+ // full-screen menu over the page rather than a rail squeezing it, and hidden
1033
+ // until opened. DXDashboard pairs this with its phone behaviour (starts
1034
+ // closed, closes on a link or Escape, holds focus) and puts
1035
+ // `dx-dashboard-menu-open` on <html> while it is open, so the page behind
1036
+ // cannot scroll. A standalone DXDashboardSidebar keeps its rail. The JS reads
1037
+ // the width from `--dx-dashboard-phone-max-width`, published here from
1038
+ // `$grid-breakpoints`, so a custom `sm` moves both together. Below the modal
1039
+ // layer (1055), so a modal opened from the menu still sits on top.
1040
+ :root {
1041
+ --dx-dashboard-phone-max-width: #{breakpoint-max(sm, $grid-breakpoints)};
1042
+ }
1043
+
1044
+ @include media-breakpoint-down(sm) {
1045
+ // `aside` outranks the component's scoped sticky/100vh/flex rules (same
1046
+ // class count) whatever order the two land in the stylesheet. The open
1047
+ // class is set only once mounted, so a server-rendered page never paints an
1048
+ // open menu before hydration.
1049
+ aside.dashboard-sidebar--phone-menu:not(.dashboard-sidebar--phone-menu-open) {
1050
+ display: none;
1051
+ }
1052
+
1053
+ aside.dashboard-sidebar--phone-menu.dashboard-sidebar--phone-menu-open {
1054
+ position: fixed;
1055
+ inset: 0;
1056
+ z-index: $zindex-offcanvas;
1057
+ width: 100%;
1058
+ height: auto;
1059
+ min-height: 0;
1060
+ }
1061
+
1062
+ html.dx-dashboard-menu-open,
1063
+ html.dx-dashboard-menu-open body {
1064
+ overflow: hidden;
1065
+ }
1066
+ }
1067
+
777
1068
  .dashboard-navbar {
778
1069
  // Published so slot content can size itself to the budget rather than guess:
779
1070
  // .my-navbar-action { height: var(--dx-navbar-content-height); }
780
- --dx-navbar-height: #{$dashboard-navbar-height};
1071
+ --dx-navbar-height: var(--dx-dashboard-header-height);
781
1072
  --dx-navbar-content-height: #{$dashboard-navbar-content-height};
782
1073
 
783
1074
  // Floor, not a fixed height: below `md` the search (and actions) drop to
784
1075
  // their own rows and the bar must grow to contain them (#93). Single-row
785
1076
  // content is shorter than this, so desktop stays exactly this height,
786
1077
  // aligned with the sidebar header.
787
- min-height: $dashboard-navbar-height;
1078
+ min-height: var(--dx-dashboard-header-height);
788
1079
  // A light grey top bar over the white content panel (higher contrast than a
789
- // grey-everywhere layout). The navbar markup drops its `bg-white` class so
790
- // this wins without `!important`.
791
- background-color: var(--bs-light);
1080
+ // grey-everywhere layout), from the band token the light sidebar's brand row
1081
+ // shares. The bottom line is a token too (none by default); the markup has no
1082
+ // `border-bottom` utility, whose `!important` would pin it.
1083
+ background-color: var(--dx-dashboard-header-bg);
1084
+ border-bottom: var(--dx-dashboard-header-border-width) var(--bs-border-style) var(--bs-border-color);
792
1085
  }
793
1086
 
794
1087
  // The avatar dropdown trigger (DXDashboardNavbar sets this via toggle-class, so
@@ -1084,6 +1377,173 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
1084
1377
  vertical-align: middle;
1085
1378
  }
1086
1379
 
1380
+ // ----------------------------------------------------------------------------
1381
+ // DXStackingTable — a hand-built table that becomes one card per body row
1382
+ // ----------------------------------------------------------------------------
1383
+ //
1384
+ // Global, not scoped: the table is the consumer's slot content, which a
1385
+ // component's scoped styles cannot reach. The component toggles
1386
+ // `.dx-stacking-table--stacked` from its MEASURED width and writes each cell's
1387
+ // column label to `data-dx-stack-label`; everything visual is here. Only the
1388
+ // wrapper's DIRECT child table is affected, so a table nested in a cell keeps
1389
+ // its own layout.
1390
+
1391
+ // ----------------------------------------------------------------------------
1392
+ // DXCurrencyInput — keep the amount usable in a narrow container
1393
+ // ----------------------------------------------------------------------------
1394
+ //
1395
+ // Bootstrap gives an input-group's control `min-width: 0`, so a long unit
1396
+ // ("10L Bag in Box") crushed the amount to ~32px. The amount keeps room for a
1397
+ // price, the group still does not wrap (a wrapped affix would land on its own line
1398
+ // with broken corners: the theme already makes every `.input-group` nowrap),
1399
+ // and the affix text wraps onto two lines instead.
1400
+ .dx-currency-input {
1401
+ > .form-control {
1402
+ min-width: 5.5rem;
1403
+ }
1404
+
1405
+ > .input-group-text {
1406
+ white-space: normal;
1407
+ }
1408
+ }
1409
+
1410
+ // What a stacked card line stretches: a form control, or an element wrapping
1411
+ // one. Not buttons, which keep their size.
1412
+ $dx-stack-controls: 'input:not([type="hidden"]), select, textarea, .form-control, .form-select, .input-group, [contenteditable]';
1413
+ $dx-stack-fill: ':is(#{$dx-stack-controls}):not(button, .btn), :not(button, .btn):has(#{$dx-stack-controls})';
1414
+
1415
+ // Wide layout: the wrapper is the old `.table-responsive` (a horizontal scroll
1416
+ // container), which also keeps its measured width equal to the space it has.
1417
+ .dx-stacking-table {
1418
+ overflow-x: auto;
1419
+ -webkit-overflow-scrolling: touch;
1420
+ }
1421
+
1422
+ .dx-stacking-table--stacked > table {
1423
+ display: block;
1424
+ width: 100%;
1425
+
1426
+ > thead {
1427
+ display: none;
1428
+ }
1429
+
1430
+ > tbody,
1431
+ > tfoot,
1432
+ > tbody > tr,
1433
+ > tfoot > tr {
1434
+ display: block;
1435
+ }
1436
+
1437
+ // Each body row is a card.
1438
+ > tbody > tr {
1439
+ border: var(--bs-border-width) solid var(--bs-border-color);
1440
+ border-radius: var(--bs-border-radius);
1441
+ padding: 0.25rem $table-cell-padding-x;
1442
+ margin-bottom: 0.75rem;
1443
+ }
1444
+
1445
+ // A `.dx-stack-continue` row (a note under a line) joins the card above it.
1446
+ > tbody > tr:has(+ tr.dx-stack-continue) {
1447
+ margin-bottom: 0;
1448
+ border-bottom-width: 0;
1449
+ border-bottom-left-radius: 0;
1450
+ border-bottom-right-radius: 0;
1451
+ }
1452
+
1453
+ > tbody > tr.dx-stack-continue {
1454
+ border-top-width: 0;
1455
+ border-top-left-radius: 0;
1456
+ border-top-right-radius: 0;
1457
+ }
1458
+
1459
+ // Footer rows (a total) are not cards: they line up with the cards' content.
1460
+ // The transparent border matches the cards' box exactly, so the 33% label
1461
+ // column resolves to the same width and the total sits on the same edge.
1462
+ > tfoot > tr {
1463
+ padding: 0 $table-cell-padding-x;
1464
+ border: var(--bs-border-width) solid transparent;
1465
+ }
1466
+
1467
+ > tbody > tr > *,
1468
+ > tfoot > tr > * {
1469
+ display: block;
1470
+ width: 100%;
1471
+ padding: 0.5rem 0;
1472
+ border-bottom-width: 0;
1473
+ }
1474
+
1475
+ // A labelled cell is a row of the card: a fixed label column, then its
1476
+ // content, so every control in a card starts at the same left edge (like a
1477
+ // horizontal form's label column). The column is a third of the card,
1478
+ // clamped so short labels waste no space and long ones wrap instead of
1479
+ // pushing the control. Tune it with `--dx-stacking-table-label-width` on the
1480
+ // wrapper or any ancestor (deliberately NOT declared here, so an ancestor's
1481
+ // value is not shadowed).
1482
+ //
1483
+ // Values start at that edge too (text-align: start, overriding a cell's
1484
+ // `.text-end`/`.text-center`, which only meant something in a column):
1485
+ // a total reads as one more line of the same column, not a stray figure at
1486
+ // the far right.
1487
+ //
1488
+ // `.dx-stack-hide` is excluded HERE rather than out-ranked below: this
1489
+ // selector is (0,2,0) to the hide rule's (0,1,0), so a hidden cell that has a
1490
+ // label would otherwise stay on screen as a flex line.
1491
+ > tbody > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span, .dx-stack-hide),
1492
+ > tfoot > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span, .dx-stack-hide) {
1493
+ display: flex;
1494
+ align-items: center;
1495
+ justify-content: flex-start;
1496
+ gap: 1rem;
1497
+ text-align: start !important;
1498
+ }
1499
+
1500
+ // A VALUE (text, a currency span) keeps its natural width, so it starts at
1501
+ // the label edge even when it, or something inside it, aligns itself to the
1502
+ // end (`.text-end`, `d-block text-end`): stretched, that end-alignment would
1503
+ // carry it to the card's right edge.
1504
+ > tbody > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span) > *,
1505
+ > tfoot > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span) > * {
1506
+ flex: 0 1 auto;
1507
+ min-width: 0;
1508
+ }
1509
+
1510
+ // A CONTROL (or a wrapper holding one) fills the rest of the line: an input
1511
+ // group or a number input stretches to the card's edge. Buttons keep their
1512
+ // own size. Same control list as the horizontal form's display-only rule.
1513
+ > tbody > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span) > :is(#{$dx-stack-fill}),
1514
+ > tfoot > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span) > :is(#{$dx-stack-fill}) {
1515
+ flex: 1 1 0;
1516
+ }
1517
+
1518
+ > tbody > tr > [data-dx-stack-label]:not(.dx-stack-span)::before,
1519
+ > tfoot > tr > [data-dx-stack-label]:not(.dx-stack-span)::before {
1520
+ content: attr(data-dx-stack-label);
1521
+ }
1522
+
1523
+ // A consumer `data-label` wins over the derived one (same specificity, later).
1524
+ > tbody > tr > [data-label]:not(.dx-stack-span)::before,
1525
+ > tfoot > tr > [data-label]:not(.dx-stack-span)::before {
1526
+ content: attr(data-label);
1527
+ }
1528
+
1529
+ // The label reads as the header it replaces.
1530
+ > tbody > tr > *::before,
1531
+ > tfoot > tr > *::before {
1532
+ flex: 0 0 var(--dx-stacking-table-label-width, clamp(5rem, 33%, 10rem));
1533
+ text-align: start;
1534
+ overflow-wrap: anywhere;
1535
+ font-weight: $table-th-font-weight;
1536
+ color: var(--dx-table-header-color);
1537
+ }
1538
+
1539
+ // Hidden when stacked: opted out, or an empty cell with no label (the
1540
+ // `&nbsp;` filler beside a footer total).
1541
+ > tbody > tr > :is(.dx-stack-hide, [data-dx-stack-empty]),
1542
+ > tfoot > tr > :is(.dx-stack-hide, [data-dx-stack-empty]) {
1543
+ display: none;
1544
+ }
1545
+ }
1546
+
1087
1547
  // PL1: a magnifier glyph on DXTable's text/number filter inputs, so the filter
1088
1548
  // row reads as "this searches" at a glance (legacy lt-table carried one).
1089
1549
  //
@@ -1463,17 +1923,21 @@ $dx-chart-edge-dark: $body-bg-dark;
1463
1923
  // utilities and not the media query, leaving a band of widths where the two
1464
1924
  // disagree (#101).
1465
1925
  //
1466
- // Mobile-first: the actions and the search each take their own full-width row
1467
- // below the toggle / title / user-menu row (flex-basis 100% forces the wrap; the
1468
- // bar and the sticky header grow to contain them — #93). The actions row itself
1469
- // wraps, so a wide button group stacks instead of overflowing the viewport.
1926
+ // At every width the search sits inline between the toggle (and title, from
1927
+ // `md` up) and the user menu, filling the middle: one row on a phone, with the
1928
+ // region never narrower than $dashboard-navbar-search-min-width (below that it
1929
+ // wraps onto a row of its own rather than becoming unusable).
1470
1930
  //
1471
- // From `md` up everything goes inline: search between the title and the actions
1472
- // (filling the middle; `searchAlign` places its content), actions right-aligned
1473
- // next to the user menu. The actions' `order: 2` holds at every size — only the
1474
- // search and the user-menu cluster swap order across the breakpoint.
1475
-
1931
+ // Mobile-first: the actions take their own full-width row below that one
1932
+ // (flex-basis 100% forces the wrap; the bar and the sticky header grow to
1933
+ // contain it — #93). The actions row itself wraps, so a wide button group
1934
+ // stacks instead of overflowing the viewport. From `md` up the actions go
1935
+ // inline too, right-aligned next to the user menu.
1936
+
1937
+ // The bar fills the header (less its bottom border), so its single row is
1938
+ // centred in the same height as the sidebar's brand row.
1476
1939
  .dashboard-navbar__bar {
1940
+ min-height: calc(var(--dx-dashboard-header-height) - var(--dx-dashboard-header-border-width));
1477
1941
  padding: $dashboard-navbar-bar-padding-y 0;
1478
1942
  }
1479
1943
 
@@ -1481,24 +1945,32 @@ $dx-chart-edge-dark: $body-bg-dark;
1481
1945
  margin-left: auto;
1482
1946
  }
1483
1947
 
1484
- .dashboard-navbar__actions {
1948
+ .dashboard-navbar__search {
1949
+ order: 1;
1950
+ flex: 1 1 0;
1951
+ min-width: $dashboard-navbar-search-min-width;
1952
+ container: dx-navbar-search / inline-size;
1953
+ }
1954
+
1955
+ @container dx-navbar-search (max-width: #{$dashboard-navbar-search-hint-min-width}) {
1956
+ .dashboard-navbar__search kbd {
1957
+ display: none;
1958
+ }
1959
+ }
1960
+
1961
+ .dashboard-navbar__end {
1485
1962
  order: 2;
1486
- flex: 0 0 100%;
1487
- flex-wrap: wrap;
1488
1963
  }
1489
1964
 
1490
- .dashboard-navbar__search {
1965
+ .dashboard-navbar__actions {
1491
1966
  order: 3;
1492
1967
  flex: 0 0 100%;
1968
+ flex-wrap: wrap;
1493
1969
  }
1494
1970
 
1495
1971
  @include media-breakpoint-up(md) {
1496
- .dashboard-navbar__search {
1497
- order: 1;
1498
- flex: 1 1 auto;
1499
- }
1500
-
1501
1972
  .dashboard-navbar__actions {
1973
+ order: 2;
1502
1974
  flex: 0 1 auto;
1503
1975
  flex-wrap: nowrap;
1504
1976
  margin-left: auto;
@@ -18,8 +18,11 @@ import { BFormSelect } from "bootstrap-vue-next";
18
18
  import { computed, useSlots } from "vue";
19
19
 
20
20
  // Mirrors the shapes BVN's BFormSelect accepts per option (primitive, object, or
21
- // group). Only object options can carry a `null` value.
22
- export type OptionItem = string | number | boolean | Record<string, unknown>;
21
+ // group): bvn types them `object | string | number | boolean`. `object`, not
22
+ // `Record<string, unknown>`, so an option INTERFACE such as DXTable's
23
+ // `FilterOption` (no index signature) fits without a cast (#171). Only object
24
+ // options can carry a `null` value.
25
+ export type OptionItem = string | number | boolean | object;
23
26
 
24
27
  export interface Props<TValue = unknown> {
25
28
  modelValue?: TValue | TValue[] | null;