@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.
@@ -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
  // ----------------------------------------------------------------------------
@@ -722,6 +740,23 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
722
740
 
723
741
  :root {
724
742
  --dx-dashboard-gutter-x: #{$dashboard-gutter-x};
743
+ --dx-dashboard-header-height: #{$dashboard-navbar-height};
744
+ // The top band (2026-10-06, "A2"): ONE background for the navbar AND the
745
+ // light sidebar's brand row, so the band runs the full width and the two can
746
+ // never differ, and no line under it. The dark sidebar keeps its own navy
747
+ // brand row and the line under both (`.dashboard-layout--sidebar-dark`).
748
+ --dx-dashboard-header-bg: var(--bs-light);
749
+ --dx-dashboard-header-border-width: 0px; // with a unit: it is subtracted in calc()
750
+ }
751
+
752
+ .dashboard-layout--sidebar-dark {
753
+ --dx-dashboard-header-border-width: #{$dashboard-navbar-border-width};
754
+ }
755
+
756
+ @include media-breakpoint-down(sm) {
757
+ :root {
758
+ --dx-dashboard-gutter-x: #{$dashboard-gutter-x-phone};
759
+ }
725
760
  }
726
761
 
727
762
  // Both are the `DContainer fluid` directly inside the navbar header and the
@@ -736,8 +771,103 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
736
771
  padding-right: var(--dx-dashboard-gutter-x);
737
772
  }
738
773
 
774
+ // Sidebar colour schemes (DXDashboard `sidebarVariant`, DXDashboardSidebar
775
+ // `variant`). Every colour, the typeface and the weights come from a
776
+ // `--dx-sidebar-*` custom property, so an install can rebrand either scheme in
777
+ // CSS without Sass: override on `:root` for the light default, on
778
+ // `.dashboard-sidebar--dark` for the dark one.
779
+ //
780
+ // LIGHT (the default since 0.43) follows the legacy Omni Tend admin: a white
781
+ // pane with no edge line and no line under the brand row, tinted group-header
782
+ // pills, grey links, a green active
783
+ // item, Poppins (500 links, 600 active and headers). Links are #6b7182
784
+ // (4.87:1 on white); the legacy #7c8293 is 3.84:1 and fails WCAG AA.
785
+ //
786
+ // DARK is the navy rail dfl shipped before, value for value. Its typeface and
787
+ // link size/weight are `initial` (no value), which makes those declarations
788
+ // fall back to inheriting from the page, exactly as before the tokens existed.
789
+ $dx-sidebar-bg: #ffffff !default;
790
+ $dx-sidebar-color: #121419 !default;
791
+ $dx-sidebar-edge-width: 0 !default;
792
+ $dx-sidebar-edge-color: #e6e9f0 !default;
793
+ $dx-sidebar-header-border-width: 0 !default;
794
+ $dx-sidebar-separator-color: #e6e9f0 !default;
795
+ $dx-sidebar-group-color: #121419 !default;
796
+ $dx-sidebar-group-bg: #f5f8fe !default;
797
+ $dx-sidebar-group-hover-bg: #ebf0fb !default;
798
+ $dx-sidebar-link-color: #6b7182 !default;
799
+ $dx-sidebar-link-hover-color: #363e4d !default;
800
+ $dx-sidebar-link-hover-bg: #f9f9fc !default;
801
+ $dx-sidebar-active-color: #121419 !default;
802
+ $dx-sidebar-active-bg: #c3faaa !default;
803
+ $dx-sidebar-focus-ring: #6b7182 !default;
804
+ $dx-sidebar-scrollbar-track: rgba($black, 0.04) !default;
805
+ $dx-sidebar-scrollbar-thumb: rgba($black, 0.18) !default;
806
+ $dx-sidebar-scrollbar-thumb-hover: rgba($black, 0.28) !default;
807
+ $dx-sidebar-font-family: var(--dx-font-family-display) !default;
808
+ $dx-sidebar-link-font-size: 0.9375rem !default;
809
+ $dx-sidebar-link-font-weight: 500 !default;
810
+ $dx-sidebar-active-font-weight: 600 !default;
811
+ $dx-sidebar-group-font-weight: 600 !default;
812
+
813
+ :root {
814
+ --dx-sidebar-bg: #{$dx-sidebar-bg};
815
+ --dx-sidebar-color: #{$dx-sidebar-color};
816
+ --dx-sidebar-edge-width: #{$dx-sidebar-edge-width};
817
+ --dx-sidebar-edge-color: #{$dx-sidebar-edge-color};
818
+ --dx-sidebar-header-border-width: #{$dx-sidebar-header-border-width};
819
+ --dx-sidebar-separator-color: #{$dx-sidebar-separator-color};
820
+ --dx-sidebar-group-color: #{$dx-sidebar-group-color};
821
+ --dx-sidebar-group-bg: #{$dx-sidebar-group-bg};
822
+ --dx-sidebar-group-hover-bg: #{$dx-sidebar-group-hover-bg};
823
+ --dx-sidebar-link-color: #{$dx-sidebar-link-color};
824
+ --dx-sidebar-link-hover-color: #{$dx-sidebar-link-hover-color};
825
+ --dx-sidebar-link-hover-bg: #{$dx-sidebar-link-hover-bg};
826
+ --dx-sidebar-active-color: #{$dx-sidebar-active-color};
827
+ --dx-sidebar-active-bg: #{$dx-sidebar-active-bg};
828
+ --dx-sidebar-focus-ring: #{$dx-sidebar-focus-ring};
829
+ --dx-sidebar-divider-color: rgba(var(--bs-secondary-rgb), 1);
830
+ --dx-sidebar-scrollbar-track: #{$dx-sidebar-scrollbar-track};
831
+ --dx-sidebar-scrollbar-thumb: #{$dx-sidebar-scrollbar-thumb};
832
+ --dx-sidebar-scrollbar-thumb-hover: #{$dx-sidebar-scrollbar-thumb-hover};
833
+ --dx-sidebar-font-family: #{$dx-sidebar-font-family};
834
+ --dx-sidebar-link-font-size: #{$dx-sidebar-link-font-size};
835
+ --dx-sidebar-link-font-weight: #{$dx-sidebar-link-font-weight};
836
+ --dx-sidebar-active-font-weight: #{$dx-sidebar-active-font-weight};
837
+ --dx-sidebar-group-font-weight: #{$dx-sidebar-group-font-weight};
838
+ }
839
+
840
+ .dashboard-sidebar--dark {
841
+ --dx-sidebar-bg: #{$dark};
842
+ --dx-sidebar-color: #{$white};
843
+ --dx-sidebar-edge-width: 0;
844
+ --dx-sidebar-edge-color: transparent;
845
+ --dx-sidebar-header-border-width: 1px;
846
+ --dx-sidebar-header-bg: transparent;
847
+ --dx-sidebar-separator-color: #{$navbar-dark-toggler-border-color};
848
+ --dx-sidebar-group-color: #{$navbar-dark-color};
849
+ --dx-sidebar-group-bg: transparent;
850
+ --dx-sidebar-group-hover-bg: #{rgba($white, 0.08)};
851
+ --dx-sidebar-link-color: #{$navbar-dark-hover-color};
852
+ --dx-sidebar-link-hover-color: #{$navbar-dark-active-color};
853
+ --dx-sidebar-link-hover-bg: #{rgba($white, 0.1)};
854
+ --dx-sidebar-active-color: #{$navbar-dark-active-color};
855
+ --dx-sidebar-active-bg: #{rgba($white, 0.15)};
856
+ --dx-sidebar-focus-ring: #{rgba($white, 0.5)};
857
+ --dx-sidebar-scrollbar-track: #{rgba($black, 0.1)};
858
+ --dx-sidebar-scrollbar-thumb: #{rgba($white, 0.2)};
859
+ --dx-sidebar-scrollbar-thumb-hover: #{rgba($white, 0.3)};
860
+ --dx-sidebar-font-family: initial;
861
+ --dx-sidebar-link-font-size: initial;
862
+ --dx-sidebar-link-font-weight: initial;
863
+ --dx-sidebar-active-font-weight: 500;
864
+ --dx-sidebar-group-font-weight: 600;
865
+ }
866
+
739
867
  .dashboard-sidebar {
740
- background-color: $dark;
868
+ background-color: var(--dx-sidebar-bg);
869
+ color: var(--dx-sidebar-color);
870
+ border-right: var(--dx-sidebar-edge-width) solid var(--dx-sidebar-edge-color);
741
871
  width: $dashboard-sidebar-width;
742
872
 
743
873
  &.sidebar-collapsed {
@@ -745,50 +875,157 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
745
875
  }
746
876
 
747
877
  .sidebar-header {
748
- height: $dashboard-navbar-height;
749
- border-bottom-color: $navbar-dark-toggler-border-color;
878
+ height: var(--dx-dashboard-header-height);
879
+ // Light: the navbar's band colour, read from the same token at this
880
+ // element, so a consumer override anywhere above reaches both. Dark sets
881
+ // `--dx-sidebar-header-bg: transparent` (the navy pane shows through).
882
+ background-color: var(--dx-sidebar-header-bg, var(--dx-dashboard-header-bg));
883
+ }
884
+
885
+ .sidebar-nav,
886
+ .sidebar-footer {
887
+ font-family: var(--dx-sidebar-font-family);
750
888
  }
751
889
 
752
890
  .nav-group-label,
753
891
  .nav-group-toggle {
754
- color: $navbar-dark-color;
892
+ color: var(--dx-sidebar-group-color);
893
+ font-weight: var(--dx-sidebar-group-font-weight);
755
894
  }
756
895
 
757
- .nav-group-divider {
758
- border-color: $navbar-dark-toggler-border-color;
896
+ .nav-group-toggle {
897
+ background-color: var(--dx-sidebar-group-bg);
898
+
899
+ &:hover {
900
+ background-color: var(--dx-sidebar-group-hover-bg);
901
+ }
902
+
903
+ &:focus-visible {
904
+ outline: 2px solid var(--dx-sidebar-focus-ring);
905
+ outline-offset: 2px;
906
+ }
907
+ }
908
+
909
+ .nav-group-divider hr {
910
+ border-color: var(--dx-sidebar-divider-color);
759
911
  }
760
912
 
761
913
  .nav-link {
762
- color: $navbar-dark-hover-color;
914
+ color: var(--dx-sidebar-link-color);
915
+ font-size: var(--dx-sidebar-link-font-size);
916
+ font-weight: var(--dx-sidebar-link-font-weight);
763
917
 
764
918
  &:hover {
765
- color: $navbar-dark-active-color;
766
- background-color: rgba($white, 0.1);
919
+ color: var(--dx-sidebar-link-hover-color);
920
+ background-color: var(--dx-sidebar-link-hover-bg);
921
+ }
922
+
923
+ // Bootstrap's own ring is a primary-tinted box-shadow, which disappears
924
+ // against the dark pane; an outline in the scheme's ring colour shows on
925
+ // both.
926
+ &:focus-visible {
927
+ outline: 2px solid var(--dx-sidebar-focus-ring);
928
+ outline-offset: 2px;
929
+ box-shadow: none;
767
930
  }
768
931
 
769
932
  &.active {
770
- color: $navbar-dark-active-color;
771
- background-color: rgba($white, 0.15);
772
- font-weight: 500;
933
+ color: var(--dx-sidebar-active-color);
934
+ background-color: var(--dx-sidebar-active-bg);
935
+ font-weight: var(--dx-sidebar-active-font-weight);
773
936
  }
774
937
  }
775
938
  }
776
939
 
940
+ // Light geometry: the group headers are pills (static labels too, so the
941
+ // default non-collapsible sidebar has them) and the links line up with the
942
+ // pill text. `aside` outranks the component's scoped padding and margin (same
943
+ // class count, but those land later in the stylesheet). The collapsed rail
944
+ // keeps its centred icon padding.
945
+ //
946
+ // The gap under a pill is on its first item rather than the pill itself:
947
+ // inside a closed collapsible group that item is clipped away with the rest,
948
+ // so closed pills sit exactly the group gap (1rem) apart, as in the legacy
949
+ // admin, instead of 1rem plus the header's own margin.
950
+ aside.dashboard-sidebar--light {
951
+ .nav-group-label,
952
+ .nav-group-toggle {
953
+ display: flex;
954
+ align-items: center;
955
+ min-height: 2.2rem;
956
+ margin-bottom: 0;
957
+ padding-left: 1rem;
958
+ padding-right: 0.9rem;
959
+ background-color: var(--dx-sidebar-group-bg);
960
+ border-radius: $border-radius;
961
+ }
962
+
963
+ .nav-group-label + .nav-group-items > .nav > .nav-item:first-child,
964
+ .nav-group-toggle + .nav-group-items > .nav > .nav-item:first-child {
965
+ margin-top: 0.25rem;
966
+ }
967
+
968
+ &:not(.sidebar-collapsed) .nav-link {
969
+ padding-left: 1rem;
970
+ border-radius: $border-radius;
971
+ }
972
+ }
973
+
974
+ // Phones (below `sm`, where DModal also goes full screen): the sidebar
975
+ // DXDashboard owns (`phoneMenu`, class `dashboard-sidebar--phone-menu`) is a
976
+ // full-screen menu over the page rather than a rail squeezing it, and hidden
977
+ // until opened. DXDashboard pairs this with its phone behaviour (starts
978
+ // closed, closes on a link or Escape, holds focus) and puts
979
+ // `dx-dashboard-menu-open` on <html> while it is open, so the page behind
980
+ // cannot scroll. A standalone DXDashboardSidebar keeps its rail. The JS reads
981
+ // the width from `--dx-dashboard-phone-max-width`, published here from
982
+ // `$grid-breakpoints`, so a custom `sm` moves both together. Below the modal
983
+ // layer (1055), so a modal opened from the menu still sits on top.
984
+ :root {
985
+ --dx-dashboard-phone-max-width: #{breakpoint-max(sm, $grid-breakpoints)};
986
+ }
987
+
988
+ @include media-breakpoint-down(sm) {
989
+ // `aside` outranks the component's scoped sticky/100vh/flex rules (same
990
+ // class count) whatever order the two land in the stylesheet. The open
991
+ // class is set only once mounted, so a server-rendered page never paints an
992
+ // open menu before hydration.
993
+ aside.dashboard-sidebar--phone-menu:not(.dashboard-sidebar--phone-menu-open) {
994
+ display: none;
995
+ }
996
+
997
+ aside.dashboard-sidebar--phone-menu.dashboard-sidebar--phone-menu-open {
998
+ position: fixed;
999
+ inset: 0;
1000
+ z-index: $zindex-offcanvas;
1001
+ width: 100%;
1002
+ height: auto;
1003
+ min-height: 0;
1004
+ }
1005
+
1006
+ html.dx-dashboard-menu-open,
1007
+ html.dx-dashboard-menu-open body {
1008
+ overflow: hidden;
1009
+ }
1010
+ }
1011
+
777
1012
  .dashboard-navbar {
778
1013
  // Published so slot content can size itself to the budget rather than guess:
779
1014
  // .my-navbar-action { height: var(--dx-navbar-content-height); }
780
- --dx-navbar-height: #{$dashboard-navbar-height};
1015
+ --dx-navbar-height: var(--dx-dashboard-header-height);
781
1016
  --dx-navbar-content-height: #{$dashboard-navbar-content-height};
782
1017
 
783
1018
  // Floor, not a fixed height: below `md` the search (and actions) drop to
784
1019
  // their own rows and the bar must grow to contain them (#93). Single-row
785
1020
  // content is shorter than this, so desktop stays exactly this height,
786
1021
  // aligned with the sidebar header.
787
- min-height: $dashboard-navbar-height;
1022
+ min-height: var(--dx-dashboard-header-height);
788
1023
  // 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);
1024
+ // grey-everywhere layout), from the band token the light sidebar's brand row
1025
+ // shares. The bottom line is a token too (none by default); the markup has no
1026
+ // `border-bottom` utility, whose `!important` would pin it.
1027
+ background-color: var(--dx-dashboard-header-bg);
1028
+ border-bottom: var(--dx-dashboard-header-border-width) var(--bs-border-style) var(--bs-border-color);
792
1029
  }
793
1030
 
794
1031
  // The avatar dropdown trigger (DXDashboardNavbar sets this via toggle-class, so
@@ -1084,6 +1321,173 @@ $dx-display-only-row: '.dx-form--horizontal .row:has(> .col-form-label):not(:has
1084
1321
  vertical-align: middle;
1085
1322
  }
1086
1323
 
1324
+ // ----------------------------------------------------------------------------
1325
+ // DXStackingTable — a hand-built table that becomes one card per body row
1326
+ // ----------------------------------------------------------------------------
1327
+ //
1328
+ // Global, not scoped: the table is the consumer's slot content, which a
1329
+ // component's scoped styles cannot reach. The component toggles
1330
+ // `.dx-stacking-table--stacked` from its MEASURED width and writes each cell's
1331
+ // column label to `data-dx-stack-label`; everything visual is here. Only the
1332
+ // wrapper's DIRECT child table is affected, so a table nested in a cell keeps
1333
+ // its own layout.
1334
+
1335
+ // ----------------------------------------------------------------------------
1336
+ // DXCurrencyInput — keep the amount usable in a narrow container
1337
+ // ----------------------------------------------------------------------------
1338
+ //
1339
+ // Bootstrap gives an input-group's control `min-width: 0`, so a long unit
1340
+ // ("10L Bag in Box") crushed the amount to ~32px. The amount keeps room for a
1341
+ // price, the group still does not wrap (a wrapped affix would land on its own line
1342
+ // with broken corners: the theme already makes every `.input-group` nowrap),
1343
+ // and the affix text wraps onto two lines instead.
1344
+ .dx-currency-input {
1345
+ > .form-control {
1346
+ min-width: 5.5rem;
1347
+ }
1348
+
1349
+ > .input-group-text {
1350
+ white-space: normal;
1351
+ }
1352
+ }
1353
+
1354
+ // What a stacked card line stretches: a form control, or an element wrapping
1355
+ // one. Not buttons, which keep their size.
1356
+ $dx-stack-controls: 'input:not([type="hidden"]), select, textarea, .form-control, .form-select, .input-group, [contenteditable]';
1357
+ $dx-stack-fill: ':is(#{$dx-stack-controls}):not(button, .btn), :not(button, .btn):has(#{$dx-stack-controls})';
1358
+
1359
+ // Wide layout: the wrapper is the old `.table-responsive` (a horizontal scroll
1360
+ // container), which also keeps its measured width equal to the space it has.
1361
+ .dx-stacking-table {
1362
+ overflow-x: auto;
1363
+ -webkit-overflow-scrolling: touch;
1364
+ }
1365
+
1366
+ .dx-stacking-table--stacked > table {
1367
+ display: block;
1368
+ width: 100%;
1369
+
1370
+ > thead {
1371
+ display: none;
1372
+ }
1373
+
1374
+ > tbody,
1375
+ > tfoot,
1376
+ > tbody > tr,
1377
+ > tfoot > tr {
1378
+ display: block;
1379
+ }
1380
+
1381
+ // Each body row is a card.
1382
+ > tbody > tr {
1383
+ border: var(--bs-border-width) solid var(--bs-border-color);
1384
+ border-radius: var(--bs-border-radius);
1385
+ padding: 0.25rem $table-cell-padding-x;
1386
+ margin-bottom: 0.75rem;
1387
+ }
1388
+
1389
+ // A `.dx-stack-continue` row (a note under a line) joins the card above it.
1390
+ > tbody > tr:has(+ tr.dx-stack-continue) {
1391
+ margin-bottom: 0;
1392
+ border-bottom-width: 0;
1393
+ border-bottom-left-radius: 0;
1394
+ border-bottom-right-radius: 0;
1395
+ }
1396
+
1397
+ > tbody > tr.dx-stack-continue {
1398
+ border-top-width: 0;
1399
+ border-top-left-radius: 0;
1400
+ border-top-right-radius: 0;
1401
+ }
1402
+
1403
+ // Footer rows (a total) are not cards: they line up with the cards' content.
1404
+ // The transparent border matches the cards' box exactly, so the 33% label
1405
+ // column resolves to the same width and the total sits on the same edge.
1406
+ > tfoot > tr {
1407
+ padding: 0 $table-cell-padding-x;
1408
+ border: var(--bs-border-width) solid transparent;
1409
+ }
1410
+
1411
+ > tbody > tr > *,
1412
+ > tfoot > tr > * {
1413
+ display: block;
1414
+ width: 100%;
1415
+ padding: 0.5rem 0;
1416
+ border-bottom-width: 0;
1417
+ }
1418
+
1419
+ // A labelled cell is a row of the card: a fixed label column, then its
1420
+ // content, so every control in a card starts at the same left edge (like a
1421
+ // horizontal form's label column). The column is a third of the card,
1422
+ // clamped so short labels waste no space and long ones wrap instead of
1423
+ // pushing the control. Tune it with `--dx-stacking-table-label-width` on the
1424
+ // wrapper or any ancestor (deliberately NOT declared here, so an ancestor's
1425
+ // value is not shadowed).
1426
+ //
1427
+ // Values start at that edge too (text-align: start, overriding a cell's
1428
+ // `.text-end`/`.text-center`, which only meant something in a column):
1429
+ // a total reads as one more line of the same column, not a stray figure at
1430
+ // the far right.
1431
+ //
1432
+ // `.dx-stack-hide` is excluded HERE rather than out-ranked below: this
1433
+ // selector is (0,2,0) to the hide rule's (0,1,0), so a hidden cell that has a
1434
+ // label would otherwise stay on screen as a flex line.
1435
+ > tbody > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span, .dx-stack-hide),
1436
+ > tfoot > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span, .dx-stack-hide) {
1437
+ display: flex;
1438
+ align-items: center;
1439
+ justify-content: flex-start;
1440
+ gap: 1rem;
1441
+ text-align: start !important;
1442
+ }
1443
+
1444
+ // A VALUE (text, a currency span) keeps its natural width, so it starts at
1445
+ // the label edge even when it, or something inside it, aligns itself to the
1446
+ // end (`.text-end`, `d-block text-end`): stretched, that end-alignment would
1447
+ // carry it to the card's right edge.
1448
+ > tbody > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span) > *,
1449
+ > tfoot > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span) > * {
1450
+ flex: 0 1 auto;
1451
+ min-width: 0;
1452
+ }
1453
+
1454
+ // A CONTROL (or a wrapper holding one) fills the rest of the line: an input
1455
+ // group or a number input stretches to the card's edge. Buttons keep their
1456
+ // own size. Same control list as the horizontal form's display-only rule.
1457
+ > tbody > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span) > :is(#{$dx-stack-fill}),
1458
+ > tfoot > tr > :is([data-dx-stack-label], [data-label]):not(.dx-stack-span) > :is(#{$dx-stack-fill}) {
1459
+ flex: 1 1 0;
1460
+ }
1461
+
1462
+ > tbody > tr > [data-dx-stack-label]:not(.dx-stack-span)::before,
1463
+ > tfoot > tr > [data-dx-stack-label]:not(.dx-stack-span)::before {
1464
+ content: attr(data-dx-stack-label);
1465
+ }
1466
+
1467
+ // A consumer `data-label` wins over the derived one (same specificity, later).
1468
+ > tbody > tr > [data-label]:not(.dx-stack-span)::before,
1469
+ > tfoot > tr > [data-label]:not(.dx-stack-span)::before {
1470
+ content: attr(data-label);
1471
+ }
1472
+
1473
+ // The label reads as the header it replaces.
1474
+ > tbody > tr > *::before,
1475
+ > tfoot > tr > *::before {
1476
+ flex: 0 0 var(--dx-stacking-table-label-width, clamp(5rem, 33%, 10rem));
1477
+ text-align: start;
1478
+ overflow-wrap: anywhere;
1479
+ font-weight: $table-th-font-weight;
1480
+ color: var(--dx-table-header-color);
1481
+ }
1482
+
1483
+ // Hidden when stacked: opted out, or an empty cell with no label (the
1484
+ // `&nbsp;` filler beside a footer total).
1485
+ > tbody > tr > :is(.dx-stack-hide, [data-dx-stack-empty]),
1486
+ > tfoot > tr > :is(.dx-stack-hide, [data-dx-stack-empty]) {
1487
+ display: none;
1488
+ }
1489
+ }
1490
+
1087
1491
  // PL1: a magnifier glyph on DXTable's text/number filter inputs, so the filter
1088
1492
  // row reads as "this searches" at a glance (legacy lt-table carried one).
1089
1493
  //
@@ -1463,17 +1867,21 @@ $dx-chart-edge-dark: $body-bg-dark;
1463
1867
  // utilities and not the media query, leaving a band of widths where the two
1464
1868
  // disagree (#101).
1465
1869
  //
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.
1870
+ // At every width the search sits inline between the toggle (and title, from
1871
+ // `md` up) and the user menu, filling the middle: one row on a phone, with the
1872
+ // region never narrower than $dashboard-navbar-search-min-width (below that it
1873
+ // wraps onto a row of its own rather than becoming unusable).
1470
1874
  //
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
-
1875
+ // Mobile-first: the actions take their own full-width row below that one
1876
+ // (flex-basis 100% forces the wrap; the bar and the sticky header grow to
1877
+ // contain it — #93). The actions row itself wraps, so a wide button group
1878
+ // stacks instead of overflowing the viewport. From `md` up the actions go
1879
+ // inline too, right-aligned next to the user menu.
1880
+
1881
+ // The bar fills the header (less its bottom border), so its single row is
1882
+ // centred in the same height as the sidebar's brand row.
1476
1883
  .dashboard-navbar__bar {
1884
+ min-height: calc(var(--dx-dashboard-header-height) - var(--dx-dashboard-header-border-width));
1477
1885
  padding: $dashboard-navbar-bar-padding-y 0;
1478
1886
  }
1479
1887
 
@@ -1481,24 +1889,32 @@ $dx-chart-edge-dark: $body-bg-dark;
1481
1889
  margin-left: auto;
1482
1890
  }
1483
1891
 
1484
- .dashboard-navbar__actions {
1892
+ .dashboard-navbar__search {
1893
+ order: 1;
1894
+ flex: 1 1 0;
1895
+ min-width: $dashboard-navbar-search-min-width;
1896
+ container: dx-navbar-search / inline-size;
1897
+ }
1898
+
1899
+ @container dx-navbar-search (max-width: #{$dashboard-navbar-search-hint-min-width}) {
1900
+ .dashboard-navbar__search kbd {
1901
+ display: none;
1902
+ }
1903
+ }
1904
+
1905
+ .dashboard-navbar__end {
1485
1906
  order: 2;
1486
- flex: 0 0 100%;
1487
- flex-wrap: wrap;
1488
1907
  }
1489
1908
 
1490
- .dashboard-navbar__search {
1909
+ .dashboard-navbar__actions {
1491
1910
  order: 3;
1492
1911
  flex: 0 0 100%;
1912
+ flex-wrap: wrap;
1493
1913
  }
1494
1914
 
1495
1915
  @include media-breakpoint-up(md) {
1496
- .dashboard-navbar__search {
1497
- order: 1;
1498
- flex: 1 1 auto;
1499
- }
1500
-
1501
1916
  .dashboard-navbar__actions {
1917
+ order: 2;
1502
1918
  flex: 0 1 auto;
1503
1919
  flex-wrap: nowrap;
1504
1920
  margin-left: auto;
@@ -1,5 +1,15 @@
1
+ <!--
2
+ @component
3
+ Wrapper around bootstrap-vue-next's BModal. Two defaults differ from bvn's:
4
+ `size="lg"` (room for horizontal form labels on desktop) and
5
+ `fullscreen="sm"`: below 576px the modal covers the whole screen, with the
6
+ header and footer pinned and the body scrolling between them (Bootstrap's
7
+ `.modal-fullscreen-sm-down`). A `size="sm"` modal (a short confirmation)
8
+ keeps the centred small dialog on phones too. Pass `fullscreen` explicitly to
9
+ override either way, e.g. `:fullscreen="false"` to opt out.
10
+ -->
1
11
  <template>
2
- <BModal size="lg" v-bind="$attrs">
12
+ <BModal size="lg" :fullscreen="defaultFullscreen()" v-bind="$attrs">
3
13
  <template #default="slotProps">
4
14
  <slot v-bind="slotProps" />
5
15
  <DXModalActions v-if="$slots.actions" class="mt-3">
@@ -13,10 +23,18 @@
13
23
  </template>
14
24
 
15
25
  <script setup lang="ts">
16
- import { computed, useSlots } from "vue";
26
+ import { computed, useAttrs, useSlots } from "vue";
17
27
  import { BModal } from "bootstrap-vue-next";
18
28
  import DXModalActions from "../extended/DXModalActions.vue";
19
29
 
20
30
  const slots = useSlots();
31
+ const attrs = useAttrs();
21
32
  const forwardedSlots = computed(() => Object.keys(slots).filter(name => name !== "default" && name !== "actions"));
33
+
34
+ // Full screen below `sm` unless the modal is a small dialog. An explicit
35
+ // `fullscreen` from the consumer wins: it is in $attrs, which also fall
36
+ // through onto the root BModal after its own bindings. A function, not a
37
+ // computed: `useAttrs()` is not reactive, but the template re-runs it on every
38
+ // render, which an attrs change triggers.
39
+ const defaultFullscreen = (): "sm" | undefined => (attrs.size === "sm" ? undefined : "sm");
22
40
  </script>
@@ -26,7 +26,9 @@
26
26
  it binds to this prop like any other.
27
27
  -->
28
28
  <template>
29
- <DInputGroup>
29
+ <!-- `.dx-currency-input` (theme.scss) keeps the amount usable in a narrow
30
+ container: a minimum width, and a long affix wraps instead. -->
31
+ <DInputGroup class="dx-currency-input">
30
32
  <template #prepend>
31
33
  <span class="input-group-text">{{ currencySymbol }}</span>
32
34
  </template>